A 05-01 vam afegir una dependència i l'API de CicloUrbana va quedar tancada a pany i clau: un usuari anomenat user, una contrasenya que canvia a cada arrencada, un formulari HTML que a una aplicació mòbil no li serveix de res i un 403 per CSRF cada vegada que intentem crear una estació. És segur, però és inútil. Aquesta lliçó converteix aquella bastida en una configuració deliberada.

Escriurem la primera classe real del paquet com.ciclourbana.seguretat: ConfiguracioSeguretat, amb el seu bean SecurityFilterChain. Aprendrem el DSL de lambdes de Spring Security 6 apartat per apartat, definirem el mapa complet d'accessos de la xarxa de Ribalta, entendrem per què l'ordre de les regles és el que tothom erra més, i prendrem amb criteri —no per costum ni per copiar d'internet— les quatre decisions que defineixen el caràcter de l'API: contrasenyes, CSRF, sessió i capçaleres. En acabar, CicloUrbana tindrà panys de debò, encara que les claus continuïn sent provisionals fins a 05-03.

Advertiment. Tots els usuaris, contrasenyes i orígens d'aquesta lliçó són ficticis i serveixen per a l'exemple. Les contrasenyes en clar que hi apareixen només són admissibles en un exemple didàctic executat en local; mai no s'escriuen en un fitxer versionat. Tota configuració de seguretat ha de ser revisada per un professional de seguretat abans d'exposar-se a Internet.

Contingut

  1. El model de components de Spring Security 6
  2. ConfiguracioSeguretat: la primera classe de .seguretat
  3. El DSL de lambdes, apartat per apartat
  4. authorizeHttpRequests i requestMatchers
  5. La regla d'or de l'ordre de les regles
  6. El mapa d'accessos de CicloUrbana
  7. Usuaris en memòria amb InMemoryUserDetailsManager
  8. Codificació de contrasenyes
  9. HTTP Basic i form login
  10. CSRF: què és i quan es pot desactivar
  11. Gestió de sessió: STATELESS
  12. Integrar la configuració CORS de 03-02
  13. Capçaleres de seguretat de la resposta
  14. Diverses cadenes amb @Order i securityMatcher
  15. Depurar la seguretat
  16. Errors Comuns i Consells
  17. Exercicis

  1. El model de components de Spring Security 6

Si busques exemples de Spring Security a internet, la meitat del que trobaràs no compila. El motiu és un canvi de model:

// Spring Security 5 i anteriors — ELIMINAT a la versió 6. No ho facis servir.
@Configuration
public class ConfiguracioSeguretat extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception { ... }
}

WebSecurityConfigurerAdapter va quedar obsolet a Spring Security 5.7 i es va eliminar a la 6.0. Si intentes estendre'l amb Spring Boot 3.x, el codi ni tan sols compila. La substitució no és cosmètica: és un canvi de filosofia, d'herència a composició.

Aspecte Model antic (herència) Model actual (beans)
Punt d'extensió Estendre una classe i sobreescriure mètodes Declarar beans
Diverses cadenes Diverses classes internes, ordre confús Diversos beans SecurityFilterChain amb @Order
Personalitzar l'AuthenticationManager Sobreescriure un mètode protegit Declarar un bean o exposar-lo des d'AuthenticationConfiguration
Comprovar què hi ha configurat Difícil: estat heretat Fàcil: els beans són a la vista
Encaixa amb la resta de Spring Boot Regular Igual que qualsevol altra configuració

Tres raons del canvi: l'herència obligava a un objecte amb estat el comportament del qual depenia de quins mètodes s'haguessin sobreescrit; no componia bé, perquè dues configuracions exigien classes internes amb regles d'ordenació poc evidents; i era incoherent amb la resta de Spring Boot, on tot es configura declarant beans (02-01).

La conseqüència pràctica és que tota la configuració de seguretat de CicloUrbana seran beans en una classe @Configuration normal, exactament igual que ConfiguracioCors (03-02) o ConfiguracioOpenApi (03-07).

  1. ConfiguracioSeguretat: la primera classe de .seguretat

package com.ciclourbana.seguretat;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class ConfiguracioSeguretat {

    @Bean
    SecurityFilterChain cadenaFiltres(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/estacions/**").permitAll()
                .anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults());

        return http.build();
    }
}

Quatre elements que convé entendre un per un:

@Configuration és una classe de configuració normal (02-01); res d'especial.

@EnableWebSecurity. Importa la configuració de la seguretat web. Amb Spring Boot és opcional, perquè l'autoconfiguració ja l'aplica, però es posa per dos motius: fa explícit que aquesta classe governa la seguretat, i és imprescindible quan es vol activar el mode de depuració (@EnableWebSecurity(debug = true), apartat 15).

HttpSecurity injectat com a paràmetre és un constructor de cadenes amb àmbit prototip: Spring lliura una instància nova per a cada bean SecurityFilterChain, ja preconfigurada. No el desis mai en un camp ni el comparteixis entre mètodes.

http.build() construeix la SecurityFilterChain amb els filtres corresponents al que s'hagi configurat.

L'efecte de declarar aquest bean és total: substitueix per complet la configuració per defecte de Spring Boot. L'usuari user amb contrasenya generada desapareix del log, el formulari d'inici de sessió desapareix i només queda el que escriguis. És un interruptor de tot o res: no s'«afegeixen» regles a les de per defecte, es reemplacen.

  1. El DSL de lambdes, apartat per apartat

HttpSecurity ofereix un mètode per cada aspecte configurable, i cadascun rep una lambda que el personalitza. Aquest és l'esquelet complet amb què treballarem:

http
    .securityMatcher("/api/**")                      // a quines peticions s'aplica aquesta cadena
    .authorizeHttpRequests(auth -> { ... })          // qui pot accedir a què
    .csrf(csrf -> { ... })                           // protecció anti-CSRF
    .cors(Customizer.withDefaults())                 // política CORS
    .sessionManagement(sessio -> { ... })            // política de sessió
    .httpBasic(Customizer.withDefaults())            // autenticació HTTP Basic
    .formLogin(form -> { ... })                      // formulari d'inici de sessió
    .logout(sortida -> { ... })                      // tancament de sessió
    .headers(capcaleres -> { ... })                  // capçaleres de seguretat
    .exceptionHandling(ex -> { ... })                // què respondre davant 401 i 403
    .addFilterBefore(filtrePropi, AltreFiltre.class);// inserir filtres propis

Tres regles d'ús. Customizer.withDefaults() activa l'aspecte amb els seus valors per defecte. Per desactivar es fa servir la lambda amb disable(): .csrf(csrf -> csrf.disable()), o la seva forma abreujada .csrf(AbstractHttpConfigurer::disable). I no cridar un mètode no vol dir desactivar-lo: si httpBasic no hi apareix, s'aplica el valor per defecte d'aquella cadena. A Spring Security 6.1 i posteriors, a més, els mètodes encadenats sense lambda (.and(), .antMatchers()) estan obsolets o eliminats: el DSL de lambdes és l'única forma admesa, i la seva indentació mostra a simple vista on comença i on acaba cada bloc.

  1. authorizeHttpRequests i requestMatchers

És el bloc més important: defineix qui accedeix a què. La seva estructura és sempre una llista de parelles criteri → regla.

.authorizeHttpRequests(auth -> auth
    .requestMatchers(HttpMethod.GET, "/api/v1/estacions/**").permitAll()
    .requestMatchers("/api/v1/bicicletes/**").hasRole("OPERARI")
    .anyRequest().authenticated())

Formes de requestMatchers

Forma Exemple Què selecciona
Per patró requestMatchers("/api/v1/estacions/**") Qualsevol mètode sobre aquestes rutes
Per mètode i patró requestMatchers(HttpMethod.POST, "/api/v1/lloguers") Només aquest verb
Només per mètode requestMatchers(HttpMethod.OPTIONS) Qualsevol ruta amb aquest verb
Diversos patrons requestMatchers("/login", "/registre") Qualsevol d'ells
Matcher propi requestMatchers(new RegexRequestMatcher(...)) Casos que el patró no cobreix

Els patrons són de tipus PathPattern (el mateix motor de @RequestMapping, 03-02):

Comodí Significat /api/v1/estacions/1/bicicletes
? Un caràcter /api/v1/estacion? no hi coincideix
* Qualsevol text dins d'un segment /api/v1/* no hi coincideix
** Qualsevol nombre de segments /api/v1/** sí que hi coincideix
{var} Variable de ruta /api/v1/estacions/{id}/bicicletes hi coincideix

La distinció entre * i ** causa molts forats. Escriure requestMatchers("/api/v1/usuaris/*").hasRole("ADMIN") protegeix /api/v1/usuaris/7, però no /api/v1/usuaris/7/lloguers, que queda governat per la regla següent. Davant del dubte, **.

Un detall de Spring Security 6: els antics antMatchers i mvcMatchers es van unificar en requestMatchers, que tria la implementació adequada segons si hi ha o no Spring MVC al classpath.

Regles d'accés disponibles

Regla Significat Ús típic a CicloUrbana
permitAll() Accés lliure, sense autenticació Consulta pública d'estacions
authenticated() Qualsevol usuari autenticat Lloguers
hasRole("ADMIN") Té l'autoritat ROLE_ADMIN Gestió d'estacions
hasAnyRole("OPERARI", "ADMIN") Qualsevol d'aquests rols Gestió de bicicletes
hasAuthority("estacions:escriure") Té aquesta autoritat exacta, sense prefix Model de permisos granulars (05-03)
hasAnyAuthority(...) Qualsevol d'aquestes autoritats
denyAll() Ningú, mai Tancar rutes perilloses explícitament
anonymous() Només usuaris no autenticats Un registre que no s'ha de fer servir ja connectat
access(manager) Un AuthorizationManager propi Regles complexes

hasRole("ADMIN") i hasAuthority("ROLE_ADMIN") són equivalents: el primer afegeix el prefix ROLE_ automàticament. La confusió que genera això és constant i la desgranarem a 05-03; de moment n'hi ha prou amb la regla mecànica: amb hasRole no escriguis mai el prefix, amb hasAuthority escriu-lo sempre si el rol el porta.

  1. La regla d'or de l'ordre de les regles

Les regles s'avaluen en l'ordre en què es declaren, i guanya la primera que coincideix. No la més específica: la primera. Aquest és l'error més freqüent de tot el mòdul, i produeix forats silenciosos.

// ❌ CONFIGURACIÓ TRENCADA: el llistat d'usuaris queda obert a qualsevol
.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/v1/**").permitAll()                  // ← coincideix amb TOT
    .requestMatchers("/api/v1/usuaris/**").hasRole("ADMIN")     // ← inabastable
    .anyRequest().authenticated())

Una petició a GET /api/v1/usuaris coincideix amb la primera regla, que la deixa passar. La segona no es consulta mai. I el pitjor és que l'aplicació arrenca sense cap advertiment: les dades personals de tots els ciutadans de Ribalta queden públiques i res no ho indica.

// ✅ CORRECTE: del més específic al més general
.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/v1/usuaris/**").hasRole("ADMIN")
    .requestMatchers("/api/v1/**").permitAll()
    .anyRequest().authenticated())

Tres conseqüències pràctiques:

  1. Ordena d'específic a general, sempre. Rutes concretes primer, comodins amplis després.
  2. anyRequest() va al final i no es pot repetir. Si apareix abans d'una altra regla, Spring Security llança un error en arrencar (Can't configure requestMatchers after anyRequest). És l'única protecció que el framework ofereix contra aquest problema, i només cobreix aquest cas.
  3. Acaba sempre amb anyRequest().denyAll() o anyRequest().authenticated(). És la política de denegació per defecte: qualsevol endpoint nou que algú afegeixi demà neix protegit en lloc de néixer obert. És la diferència entre oblidar-se de protegir alguna cosa (perillós, silenciós) i oblidar-se d'obrir-ne alguna (molest, evident a l'acte).

  1. El mapa d'accessos de CicloUrbana

Abans d'escriure codi, la decisió de negoci. La xarxa de Ribalta té tres perfils: CIUTADA (lloga bicicletes), OPERARI (manté la flota) i ADMIN (gestiona la xarxa i els usuaris).

Endpoint Mètode Qui Justificació
/api/v1/estacions, /api/v1/estacions/{id} GET Públic El mapa d'estacions és dada oberta de l'Ajuntament
/api/v1/estacions/{id}/bicicletes GET Públic Saber si hi ha bicicletes lliures abans de registrar-se
/api/v1/estacions/** POST, PUT, PATCH, DELETE ADMIN Crear o tancar una estació és una decisió municipal
/api/v1/bicicletes/** GET Autenticat Detall de flota: bateria, incidències
/api/v1/bicicletes/** Escriptura OPERARI Altes, baixes i canvis d'estat els fa manteniment
/api/v1/incidencies/** Tots OPERARI Gestió interna d'avaries
/api/v1/lloguers/** Tots Autenticat Qui pot tocar quin es decideix a 05-05
/api/v1/usuaris/** Tots ADMIN Dades personals de ciutadans
/api/v1/auth/** POST Públic Registre i inici de sessió (05-03 i 05-04)
/swagger-ui/**, /v3/api-docs/** GET Només en desenvolupament Es tanca en producció (05-05, 07-02)
/actuator/health GET Públic El consulta el balancejador (07-01)
/actuator/** GET ADMIN Mètriques i detalls interns

I la traducció a codi, amb les regles ordenades d'específic a general:

@Bean
SecurityFilterChain cadenaApi(HttpSecurity http) throws Exception {
    http
        .securityMatcher("/api/**")
        .csrf(csrf -> csrf.disable())                         // vegeu apartat 10
        .cors(Customizer.withDefaults())                      // vegeu apartat 12
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth

            // --- Públic: autenticació i consulta oberta de la xarxa ---
            .requestMatchers(HttpMethod.POST, "/api/v1/auth/**").permitAll()
            .requestMatchers(HttpMethod.GET, "/api/v1/estacions", "/api/v1/estacions/*",
                                             "/api/v1/estacions/*/bicicletes").permitAll()

            // --- Administració de la xarxa ---
            .requestMatchers("/api/v1/usuaris/**").hasRole("ADMIN")
            .requestMatchers("/api/v1/estacions/**").hasRole("ADMIN")   // la resta de verbs

            // --- Manteniment de la flota ---
            .requestMatchers("/api/v1/incidencies/**").hasAnyRole("OPERARI", "ADMIN")
            .requestMatchers(HttpMethod.GET, "/api/v1/bicicletes/**").authenticated()
            .requestMatchers("/api/v1/bicicletes/**").hasAnyRole("OPERARI", "ADMIN")

            // --- Ús del servei ---
            .requestMatchers("/api/v1/lloguers/**").authenticated()

            // --- Denegació per defecte ---
            .anyRequest().denyAll())
        .httpBasic(Customizer.withDefaults());

    return http.build();
}

Tres detalls del disseny. Les estacions públiques fan servir /api/v1/estacions/* i no /**, perquè el comodí d'un sol segment no obri subrecursos futurs per accident. El GET de bicicletes va abans que la regla general, perquè la primera coincidència guanya. I anyRequest().denyAll() tanca qualsevol ruta sota /api/** que ningú no hagi classificat.

hasAnyRole("OPERARI", "ADMIN") repetit dues vegades és un símptoma que falta una jerarquia de rols: un ADMIN hauria de poder fer tot el d'un OPERARI sense enumerar-ho. Ho resoldrem a 05-03 amb RoleHierarchy.

  1. Usuaris en memòria amb InMemoryUserDetailsManager

Per provar les regles necessitem usuaris amb rols, i fins a 05-03 no tindrem base de dades de credencials. InMemoryUserDetailsManager és un UserDetailsService (05-01) que desa els usuaris en un mapa:

@Bean
UserDetailsService usuarisEnMemoria(PasswordEncoder codificador) {
    UserDetails marta = User.withUsername("[email protected]")
            .password(codificador.encode("clau-exemple-1"))
            .roles("CIUTADA")                      // → autoritat ROLE_CIUTADA
            .build();

    UserDetails lluis = User.withUsername("[email protected]")
            .password(codificador.encode("clau-exemple-2")).roles("OPERARI").build();

    UserDetails anna = User.withUsername("[email protected]")
            .password(codificador.encode("clau-exemple-3")).roles("ADMIN").build();

    return new InMemoryUserDetailsManager(marta, lluis, anna);
}

Tres punts importants. .roles("CIUTADA") afegeix el prefix ROLE_ automàticament: escriure .roles("ROLE_CIUTADA") produeix ROLE_ROLE_CIUTADA i llança una excepció en arrencar; per a autoritats sense prefix existeix .authorities("estacions:escriure"). User.withDefaultPasswordEncoder() està obsolet i no s'ha de fer servir ni tan sols en exemples: anima a deixar contrasenyes al codi font. I aquestes contrasenyes són fictícies i només valen en local: en un projecte real es llegirien de variables d'entorn. És bastida que desapareixerà a 05-03.

Amb això ja es poden provar les regles de l'apartat 6:

curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/v1/estacions
# 200  → públic

curl -s -o /dev/null -w '%{http_code}\n' -u [email protected]:clau-exemple-1 \
     http://localhost:8080/api/v1/usuaris           # 403 → autenticada, però no és ADMIN

curl -s -o /dev/null -w '%{http_code}\n' -u [email protected]:clau-exemple-3 \
     http://localhost:8080/api/v1/usuaris           # 200 → ADMIN

curl -s -o /dev/null -w '%{http_code}\n' \
     http://localhost:8080/api/v1/lloguers/7        # 401 → sense credencials

Aquests quatre codis són la demostració que la configuració funciona: 200 públic, 401 sense identitat, 403 amb identitat insuficient i 200 amb el rol correcte.

  1. Codificació de contrasenyes

La regla és absoluta: una contrasenya no es desa mai de manera que es pugui recuperar. Ni en clar, ni xifrada amb una clau que estigui al mateix sistema. Se'n desa un resum (hash), i només es comparen resums.

I no val qualsevol resum. MD5 i SHA-256 es van dissenyar per ser ràpids, que és just el contrari del que cal aquí: una GPU actual calcula de l'ordre de milers de milions de SHA-256 per segon, així que un diccionari de contrasenyes freqüents es prova sencer en minuts. Les funcions adequades són deliberadament lentes i porten sal (un valor aleatori per contrasenya, que impedeix precalcular taules i fa que dos usuaris amb la mateixa contrasenya tinguin resums diferents).

Algorisme Apte Notes
Text pla Mai Una fuita de base de dades regala tots els comptes
MD5, SHA-1, SHA-256 «pelats» No Ràpids per disseny; sense sal per defecte
PBKDF2 Sí Estàndard, aprovat pel NIST; el menys resistent a GPU dels tres
BCrypt Sí — elecció de CicloUrbana Madur, amb sal integrada i cost ajustable
SCrypt Sí A més exigeix memòria
Argon2id Sí, el més recomanat avui Requereix una llibreria addicional

CicloUrbana tria BCrypt: és el valor per defecte de Spring Security, no necessita dependències extra, té vint-i-cinc anys d'escrutini públic i el seu cost és ajustable.

@Bean
PasswordEncoder codificadorContrasenyes() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

Aquesta fàbrica retorna un DelegatingPasswordEncoder, i val la pena entendre per què no retornem directament un BCryptPasswordEncoder. Un resum produït pel delegador té aquest aspecte:

{bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
 ^^^^^^^^ ^^^ ^^ ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
 prefix   ver cost      sal (22)             resum (31)

El prefix entre claus identifica l'algorisme amb què es va codificar aquella contrasenya. Gràcies a ell, el sistema pot verificar contrasenyes antigues amb {pbkdf2} mentre codifica les noves amb {bcrypt}, i migrar d'algorisme sense obligar ningú a canviar la seva contrasenya. És la raó que aquest sigui el codificador recomanat fins i tot en projectes nous: avui no necessites migrar, però d'aquí a cinc anys sí.

L'error clàssic. Si a la base de dades hi ha un resum sense prefix —migrat d'un sistema anterior—, el delegador no sap quin algorisme aplicar i llança IllegalArgumentException: There is no PasswordEncoder mapped for the id "null". Hi ha dues sortides: la correcta a llarg termini és prefixar els resums existents amb una migració Flyway (UPDATE usuaris SET contrasenya_hash = '{bcrypt}' || contrasenya_hash); l'alternativa és indicar al delegador què ha de fer quan falta el prefix:

@Bean
PasswordEncoder codificadorContrasenyes() {
    var delegador = (DelegatingPasswordEncoder)
            PasswordEncoderFactories.createDelegatingPasswordEncoder();
    // Només durant una migració: els resums sense prefix es tracten com a BCrypt
    delegador.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder());
    return delegador;
}

El factor de cost. new BCryptPasswordEncoder(12) indica l'exponent del nombre d'iteracions: cada unitat duplica la feina. El valor per defecte és 10; la recomanació actual està entre 10 i 12, i el criteri pràctic és triar el cost més alt la verificació del qual continuï per sota d'uns 250 ms al teu maquinari de producció. És un equilibri explícit: apujar-lo encareix l'atac per força bruta, però també encareix cada inici de sessió legítim i pot convertir-se en un vector de denegació de servei si algú llança milers d'inicis de sessió.

Dos consells finals: codificar és lent a propòsit, així que fes-ho només en registrar i en validar, mai en un bucle; i no registris mai una contrasenya en clar en un log, ni tan sols depurant (05-05).

  1. HTTP Basic i form login

.httpBasic(Customizer.withDefaults())     // Authorization: Basic base64(u:p)
.formLogin(Customizer.withDefaults())     // formulari HTML a /login
HTTP Basic Form login
Com viatja la credencial Capçalera Authorization, a cada petició POST /login un sol cop
Estat Sense estat (però Spring crea sessió igualment si no l'hi impedeixes) Amb sessió i galeta
Client natural curl, eines, proves Navegador
Fallada 401 + WWW-Authenticate Redirecció a /login
CSRF No s'aplica a la capçalera Sí que s'aplica

formLogin admet personalització completa —loginPage("/entrar"), successHandler(...)— i és l'opció correcta per a una aplicació amb vistes al servidor. CicloUrbana desactivarà tots dos a 05-04, i convé entendre per què. El form login retorna una redirecció HTTP 302 a una pàgina HTML: una aplicació mòbil que espera JSON no sap què fer-ne. L'HTTP Basic obliga que el client desi la contrasenya de l'usuari per enviar-la a cada petició, que és exactament el que un token evita: amb JWT, la contrasenya s'envia un sol cop i el que es desa després és un token caducable i revocable. Fins llavors, mantenim httpBasic perquè fa molt còmode provar amb curl.

  1. CSRF: què és i quan es pot desactivar

CSRF (Cross-Site Request Forgery) és un atac que abusa d'una propietat del navegador: les galetes s'envien automàticament al seu domini, vingui la petició d'on vingui.

sequenceDiagram
    participant U as Navegador de la Marta
    participant M as lloc-malicios.example
    participant C as CicloUrbana

    U->>C: Inici de sessió → galeta de sessió JSESSIONID
    U->>M: Visita una pàgina qualsevol
    M-->>U: HTML amb un formulari ocult que s'autoenvia
    U->>C: POST /api/v1/lloguers/7/finalitzar<br/>amb la galeta de la Marta!
    Note over C: Sense CSRF: la petició sembla legítima<br/>Amb CSRF: falta el token → 403

La defensa és un token impredictible que el servidor lliura i que el client ha de reenviar a cada petició que modifiqui estat. El lloc maliciós no el pot llegir, perquè la política del mateix origen del navegador l'hi impedeix.

Spring Security l'activa per defecte per a POST, PUT, PATCH i DELETE (els mètodes segurs i idempotents de lectura no el necessiten, 03-03). És el que va produir el 403 de l'exercici 3 de 05-01.

Es pot desactivar a CicloUrbana? Sí, però només sota condicions estrictes, i cal enunciar-les perquè csrf.disable() és la línia que més es copia sense entendre de tot Spring Security:

Condició CicloUrbana a partir de 05-04
L'autenticació no fa servir galetes ni sessió ✅ Token Bearer a la capçalera Authorization
El navegador no adjunta la credencial automàticament ✅ La capçalera l'hi posa el codi del client, no el navegador
No hi ha formularis HTML servits per l'aplicació ✅ És una API JSON pura
La sessió és STATELESS ✅ Apartat 11
CORS està restringit a orígens coneguts ✅ ConfiguracioCors de 03-02

El raonament, en una frase: el CSRF explota que el navegador envia la credencial sola; si la credencial va en una capçalera que només el codi de la teva pròpia aplicació pot afegir, l'atac no té amb què operar.

Advertiment important. Si més endavant CicloUrbana emmagatzemés el JWT en una galeta —una opció legítima que veurem a 05-04— la condició es trenca: la galeta sí que viatja sola i el CSRF torna a ser necessari. Desactivar-lo llavors seria una vulnerabilitat real. La decisió de desactivar el CSRF depèn d'on viu la credencial, no que l'API sigui REST.

Si calgués mantenir-lo actiu amb un client JavaScript, la configuració habitual és publicar el token en una galeta llegible amb .csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())).

  1. Gestió de sessió: STATELESS

.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
Política Comportament
ALWAYS Crea sessió sempre, encara que no calgui
IF_REQUIRED Per defecte: la crea quan alguna cosa la necessita
NEVER No la crea, però fa servir la que existeixi
STATELESS Ni la crea ni la fa servir. No hi ha JSESSIONID

Amb STATELESS, cada petició s'ha d'autenticar per si mateixa. No hi ha galeta de sessió, el SecurityContext no es desa entre peticions i tot el que el servidor sap de l'usuari prové de la credencial que acaba de rebre. És exactament la restricció stateless de REST que vam estudiar a 03-01, aplicada a la seguretat.

Tres conseqüències:

  1. Escala horitzontalment sense esforç: qualsevol instància atén qualsevol petició, sense sessió apegalosa ni replicació. Importarà al mòdul 7.
  2. El CSRF deixa de ser necessari (amb les condicions de l'apartat 10), i l'HTTP Basic continua funcionant, perquè envia credencials a cada petició.
  3. No hi ha «tancar la sessió» al servidor: no hi ha res per invalidar. El problema es tracta a 05-04.

  1. Integrar la configuració CORS de 03-02

A 03-02 vam escriure ConfiguracioCors com un WebMvcConfigurer. Aquell component actua dins del DispatcherServlet, i ara hi ha filtres de seguretat al davant. El resultat és una fallada desconcertant: la petició OPTIONS de sondeig (preflight) que el navegador envia abans d'un POST no porta credencials —l'especificació ho prohibeix—, així que la seguretat la rebutja amb 401 abans que la configuració CORS arribi a respondre. El navegador informa llavors d'un error de CORS que en realitat és un error d'autenticació.

La solució és una línia:

.cors(Customizer.withDefaults())

Li diu a Spring Security que registri el seu CorsFilter dins de la cadena de seguretat, en una posició anterior a l'autorització, fent servir el CorsConfigurationSource que hi hagi al context. Perquè el trobi, convé publicar la política CORS com a bean en lloc de només com a WebMvcConfigurer:

@Bean
CorsConfigurationSource fontConfiguracioCors() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://panel.ribalta.example", "http://localhost:5173"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Rastre-Id"));
    config.setExposedHeaders(List.of("Location", "ETag", "X-Rastre-Id"));
    config.setMaxAge(3600L);
    var font = new UrlBasedCorsConfigurationSource();
    font.registerCorsConfiguration("/api/**", config);
    return font;
}

Dos afegits respecte a 03-02: Authorization entre les capçaleres permeses, sense la qual el navegador no deixaria enviar el token, i X-Rastre-Id exposada, per mostrar l'identificador de rastreig de 03-06 quan alguna cosa falli. I la regla de sempre, ara més important: mai * als orígens juntament amb credencials. L'enduriment de CORS per entorn es completa a 05-05.

  1. Capçaleres de seguretat de la resposta

Spring Security afegeix per defecte un conjunt de capçaleres que instrueixen el navegador. No costen res i eviten famílies senceres d'atacs.

Capçalera Valor per defecte Per a què serveix
X-Content-Type-Options nosniff Impedeix que el navegador endevini el tipus de contingut i interpreti com a script una cosa que no ho és
X-Frame-Options DENY Impedeix incrustar la resposta en un iframe: defensa contra el clickjacking
Cache-Control no-cache, no-store, max-age=0, must-revalidate Evita que dades privades quedin a la memòria cau del navegador o d'un proxy
Pragma, Expires no-cache, 0 El mateix per a clients antics
X-XSS-Protection 0 Desactiva un filtre obsolet de navegadors antics que era pitjor que el problema
Strict-Transport-Security Només si la petició va arribar per HTTPS Obliga el navegador a fer servir HTTPS durant el període indicat

Ajustar-les:

.headers(capcaleres -> capcaleres
    .frameOptions(f -> f.sameOrigin())    // la consola H2 de 04-02: només en desenvolupament
    .httpStrictTransportSecurity(hsts -> hsts.includeSubDomains(true)
                                             .maxAgeInSeconds(31_536_000))   // un any
    .contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'")))

L'HSTS és una decisió amb efectes duradors: un cop un navegador rep la capçalera, es nega a parlar per HTTP amb aquell domini durant el termini indicat. Si actives includeSubDomains amb un any i algun subdomini no té certificat vàlid, queda inaccessible i no hi ha manera de revertir-ho des del servidor. Comença amb un maxAge petit. Una API JSON pura no necessita CSP —no serveix HTML—, però costa poc i protegeix les pàgines que el mateix Spring serveixi, com el Swagger UI de 03-07.

  1. Diverses cadenes amb @Order i securityMatcher

Un mateix projecte sol tenir zones amb regles incompatibles. A CicloUrbana en seran tres: l'API (sense estat, amb tokens), Actuator (07-01, amb la seva pròpia política) i els recursos de documentació en desenvolupament.

@Bean
@Order(1)
SecurityFilterChain cadenaActuator(HttpSecurity http) throws Exception {
    http.securityMatcher("/actuator/**")
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/actuator/health", "/actuator/health/**").permitAll()
            .requestMatchers("/actuator/info").permitAll()
            .anyRequest().hasRole("ADMIN"))
        .csrf(csrf -> csrf.disable())
        .httpBasic(Customizer.withDefaults());
    return http.build();
}

@Bean
@Order(2)
SecurityFilterChain cadenaApi(HttpSecurity http) throws Exception {
    http.securityMatcher("/api/**")
        // ... la configuració de l'apartat 6 ...
        ;
    return http.build();
}

// @Order(3): una tercera cadena sense securityMatcher, amb anyRequest().denyAll(),
// tanca tot el que no encaixi en les dues anteriors.

Tres regles que eviten hores de desconcert:

  1. securityMatcher decideix a quines peticions s'aplica la cadena; requestMatchers, dins d'authorizeHttpRequests, decideix quin permís cal. Confondre'ls és constant.
  2. Només s'aplica la primera cadena que coincideix (05-01). Les altres no es consulten, encara que tinguin regles més específiques.
  3. El @Order més baix guanya, i la cadena sense securityMatcher coincideix amb tot, així que ha de portar el @Order més alt. Si per error quedés la primera, cap altra no s'executaria mai.

A l'arrencada, amb el log de FilterChainProxy en DEBUG, es comprova d'un cop d'ull que l'ordre és el previst.

  1. Depurar la seguretat

Dues eines resolen el noranta per cent dels problemes.

El log de depuració:

# application-dev.yml — NOMÉS en desenvolupament
logging:
  level:
    org.springframework.security: DEBUG
    org.springframework.security.web.FilterChainProxy: TRACE

Amb això, cada petició deixa un rastre que indica quin filtre l'ha atesa i, sobretot, quin l'ha rebutjada:

FilterChainProxy : Securing GET /api/v1/usuaris
AuthorizationFilter : Authorizing GET /api/v1/usuaris
AuthorizationFilter : Failed to authorize GET /api/v1/usuaris
   with authorization manager ... and decision ExpressionAuthorizationDecision
   [granted=false, expression=hasRole('ROLE_ADMIN')]

Aquesta última línia conté la resposta completa: l'expressió que ha fallat i el resultat. Mai no cal endevinar res.

L'abocament de la cadena, amb @EnableWebSecurity(debug = true) —només en desenvolupament—, imprimeix en arrencar la llista ordenada de filtres de cada cadena i, a cada petició, un resum del context de seguretat.

Advertiment. Totes dues opcions aboquen informació sensible: rutes, rols, i en el mode debug també capçaleres que poden contenir credencials. No s'han d'activar mai en producció. El seu lloc és application-dev.yml, i els perfils que garanteixen aquesta separació s'estudien a 07-02.

Errors Comuns i Consells

Posar la regla general abans que l'específica. L'error número u del mòdul. requestMatchers("/api/**").permitAll() a la primera línia obre tota l'API i no genera cap avís.

Oblidar anyRequest() al final. Sense ell, qualsevol ruta no classificada queda sense regla. Acaba sempre amb denyAll() o authenticated().

Escriure .roles("ROLE_ADMIN"). Produeix ROLE_ROLE_ADMIN. Amb roles, sense prefix; amb authorities, amb prefix.

Desactivar el CSRF «perquè és una API». Només és vàlid si la credencial no viatja sola al navegador. Amb el token en una galeta, desactivar-lo és una vulnerabilitat.

Fer servir * on calia **. /api/v1/usuaris/* no cobreix /api/v1/usuaris/7/lloguers.

Declarar dos beans SecurityFilterChain sense @Order, o deixar la cadena sense securityMatcher la primera. En el primer cas l'ordre és arbitrari; en el segon, aquella cadena coincideix amb tot i anul·la les següents.

Consell: escriu primer la taula d'accessos, després el codi. L'apartat 6 es va decidir en una taula que l'ajuntament podria revisar, i traduir-la a codi va ser mecànic; a l'inrevés, la configuració acaba sent un munt de regles que ningú no sap justificar. I prova cada regla amb curl -w '%{http_code}': quatre ordres confirmen que la política és la que et penses. A 06-04 automatitzarem aquestes comprovacions.

Exercicis

Exercici 1

Aquesta configuració té quatre problemes de seguretat o de funcionament. Troba'ls, explica l'impacte de cadascun i reescriu-la correctament.

@Bean
SecurityFilterChain cadena(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable())
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/**").permitAll()
            .requestMatchers("/api/v1/usuaris/**").hasRole("ROLE_ADMIN")
            .requestMatchers("/api/v1/bicicletes/*").hasRole("OPERARI"))
        .formLogin(Customizer.withDefaults());
    return http.build();
}

Exercici 2

Escriu la cadena de seguretat de CicloUrbana que compleixi aquests requisits, i justifica cada decisió:

  • /api/v1/estacions en GET: públic. Qualsevol escriptura sobre estacions: ADMIN.
  • Tot /api/v1/lloguers/**: autenticat.
  • /api/v1/bicicletes/** en GET: autenticat; escriptura: OPERARI o ADMIN.
  • /swagger-ui/** i /v3/api-docs/**: públic només al perfil dev.
  • Qualsevol altra ruta sota /api/**: denegada.
  • Sense estat, sense CSRF, amb CORS i HTTP Basic.

Exercici 3

Un company informa que POST /api/v1/estacions respon 403 amb les credencials d'[email protected], que és ADMIN. Enumera cinc causes possibles i descriu com distingir-les amb el log de depuració.

Solucions

Solució 1

Problema 1 — ordre invertit: /api/** amb permitAll() la primera. Coincideix amb tot, inclosos usuaris i bicicletes: les dues regles següents són inabastables i l'API sencera queda pública. És la fallada més greu i la més silenciosa.

Problema 2 — hasRole("ROLE_ADMIN") amb prefix. hasRole afegeix ROLE_, així que l'expressió efectiva busca l'autoritat ROLE_ROLE_ADMIN, que ningú no té: la regla no concedeix mai accés encara que l'ordre fos correcte. Ha de ser hasRole("ADMIN") o hasAuthority("ROLE_ADMIN").

Problema 3 — falta anyRequest(). Qualsevol ruta no llistada —/actuator/**, la consola H2, recursos estàtics— queda sense regla. S'ha de tancar amb anyRequest().denyAll().

Problema 4 — csrf.disable() al costat de formLogin. El form login fa servir galeta de sessió, i amb la credencial viatjant sola al navegador es compleix exactament l'escenari de l'atac CSRF. Aquí desactivar-lo és una vulnerabilitat real. A més, formLogin no li serveix a l'app mòbil de CicloUrbana.

@Bean
SecurityFilterChain cadena(HttpSecurity http) throws Exception {
    http
        .securityMatcher("/api/**")
        .csrf(csrf -> csrf.disable())      // vàlid: sense sessió i amb credencial a la capçalera
        .cors(Customizer.withDefaults())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/v1/usuaris/**").hasRole("ADMIN")
            .requestMatchers("/api/v1/bicicletes/**").hasAnyRole("OPERARI", "ADMIN")
            .requestMatchers(HttpMethod.GET, "/api/v1/estacions/**").permitAll()
            .anyRequest().denyAll())
        .httpBasic(Customizer.withDefaults());
    return http.build();
}

Solució 2

@Configuration
@EnableWebSecurity
public class ConfiguracioSeguretat {

    private final Environment entorn;

    public ConfiguracioSeguretat(Environment entorn) {
        this.entorn = entorn;        // Environment de 02-04
    }

    @Bean
    SecurityFilterChain cadenaApi(HttpSecurity http) throws Exception {
        boolean desenvolupament = entorn.matchesProfiles("dev");

        http
            .securityMatcher("/api/**", "/swagger-ui/**", "/v3/api-docs/**")
            .csrf(csrf -> csrf.disable())
            .cors(Customizer.withDefaults())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> {

                // La documentació, només en desenvolupament
                var docs = auth.requestMatchers("/swagger-ui/**", "/swagger-ui.html",
                                                "/v3/api-docs/**");
                if (desenvolupament) { docs.permitAll(); } else { docs.denyAll(); }

                auth
                    // Lectura pública de la xarxa; qualsevol escriptura, administració
                    .requestMatchers(HttpMethod.GET, "/api/v1/estacions/**").permitAll()
                    .requestMatchers("/api/v1/estacions/**").hasRole("ADMIN")
                    // Flota
                    .requestMatchers(HttpMethod.GET, "/api/v1/bicicletes/**").authenticated()
                    .requestMatchers("/api/v1/bicicletes/**").hasAnyRole("OPERARI", "ADMIN")
                    // Ús del servei
                    .requestMatchers("/api/v1/lloguers/**").authenticated()
                    // Denegació per defecte
                    .anyRequest().denyAll();
            })
            .httpBasic(Customizer.withDefaults());

        return http.build();
    }
}

Justificacions. Les regles d'escriptura d'estacions van abans que la de lectura pública, perquè GET ... permitAll sobre /api/v1/estacions/** no captura POST, però l'ordre explícit documenta la intenció i evita accidents si algú amplia el patró. El GET de bicicletes precedeix la regla general de bicicletes, o els ciutadans no podrien consultar la flota. La documentació es tanca amb denyAll() fora de dev en lloc de simplement ometre-la, perquè anyRequest().denyAll() ja la tancaria però una regla explícita es llegeix com una decisió, no com un descuit. I securityMatcher inclou les rutes de Swagger perquè, si no, caurien en una altra cadena. Una alternativa més neta a aquest if és tenir dos beans en classes diferents anotades amb @Profile, que és el que farem a 07-02.

Solució 3

Causa 1 — CSRF actiu. Si csrf.disable() no hi és i la petició no porta token, CsrfFilter respon 403 abans d'arribar a l'autorització. Log: Invalid CSRF token found for .... És la causa més probable amb un POST des de curl.

Causa 2 — ordre de regles. Una regla anterior més general captura la petició i exigeix un altre rol. Log: l'expressió que apareix a Failed to authorize no serà hasRole('ROLE_ADMIN'), sinó la de la regla que realment s'ha aplicat. Aquesta discrepància és el diagnòstic.

Causa 3 — prefix duplicat. Si la regla és hasRole("ROLE_ADMIN") o l'usuari es va crear amb .roles("ROLE_ADMIN"), l'autoritat buscada i la concedida no coincideixen. Log: granted=false, expression=hasRole('ROLE_ROLE_ADMIN'), o les autoritats de l'usuari impreses com a [ROLE_ROLE_ADMIN].

Causa 4 — la cadena aplicada no és la que es creu. Amb diversos beans, securityMatcher pot enviar la petició a una altra cadena; el log de FilterChainProxy en TRACE indica l'índex de la cadena triada. Causa 5 — CORS mal integrat: si el 403 només el veu un navegador, està fallant l'OPTIONS de sondeig per faltar .cors(Customizer.withDefaults()).

El mètode general, i la lliçó de la solució: activar DEBUG, llançar la petició i llegir la línia Failed to authorize. Conté l'expressió avaluada i el resultat, i descarta quatre de les cinc causes d'un cop d'ull.

Conclusió

CicloUrbana ja té una política de seguretat escrita i deliberada. Saps per què va desaparèixer WebSecurityConfigurerAdapter i per què el model actual —beans SecurityFilterChain en una classe @Configuration normal— compon millor que l'herència; has creat ConfiguracioSeguretat al paquet com.ciclourbana.seguretat i entens cada peça: @EnableWebSecurity, l'HttpSecurity injectat com a prototip i l'http.build() final que, en declarar-se, substitueix per complet els valors per defecte de Spring Boot.

Domines el DSL de lambdes: authorizeHttpRequests amb requestMatchers per patró, per mètode o per tots dos; la diferència entre * i ** que obre forats quan es confon; i el catàleg de regles, de permitAll a denyAll. Sobretot, has interioritzat la regla d'or: les regles s'avaluen en ordre i guanya la primera que coincideix, així que van d'específica a general, anyRequest() tanca sempre la llista i la política sana és la denegació per defecte. L'has vist fallar en un exemple que deixava públiques les dades personals de tots els ciutadans de Ribalta sense emetre ni un sol avís.

El mapa d'accessos està decidit i escrit: estacions públiques en lectura, lloguers per a autenticats, flota i incidències per a operaris, estacions i usuaris per a administradors, documentació només en desenvolupament. Has arrencat amb InMemoryUserDetailsManager i tres usuaris de prova —Marta ciutadana, Lluís operari, Anna administradora— i has comprovat amb curl els quatre codis que demostren que la política funciona: 200 públic, 401 sense identitat, 403 amb identitat insuficient i 200 amb el rol correcte. I saps per què no es desa mai una contrasenya recuperable, per què SHA-256 no val, què aporta BCrypt amb la seva sal i el seu factor de cost, i per què el DelegatingPasswordEncoder i el seu prefix {bcrypt} són l'elecció correcta fins i tot avui que no necessites migrar res.

Has pres a més les quatre decisions de caràcter amb criteri propi: CSRF desactivat, però només després d'enunciar les cinc condicions que ho fan segur i amb l'advertiment que un token en galeta les trenca; sessió STATELESS, coherent amb la restricció REST de 03-01; CORS integrat a la cadena amb cors(Customizer.withDefaults()) i un CorsConfigurationSource que ja permet la capçalera Authorization; i les capçaleres de seguretat, amb l'advertiment sobre com d'irreversible és un HSTS mal calibrat. Saps separar zones amb @Order i securityMatcher, i depurar amb el log d'org.springframework.security sense portar-lo mai a producció.

Queda, però, el problema evident: els usuaris viuen en un mapa en memòria. La Marta, el Lluís i l'Anna desapareixen a cada reinici, les seves contrasenyes estan escrites al codi, ningú no es pot registrar i l'Usuari que persisteix a la taula usuaris de PostgreSQL —amb el seu correu, el seu tipus de tarifa i la seva data d'alta— no té cap relació amb l'usuari que s'autentica. Són dos mons separats. A 05-03, Autenticació i Autorització d'Usuaris, els unirem: ampliarem l'entitat Usuari amb credencials i rols mitjançant la migració V4, implementarem un UserDetailsService propi que carregui per correu, crearem un UsuariAutenticat que conservi l'id, registrarem el DaoAuthenticationProvider, obrirem l'endpoint de registre de ciutadans i farem que POST /api/v1/lloguers deixi de refiar-se de l'usuariId que enviï el client. Les claus de Ribalta deixaran d'estar escrites al codi.

Curs de Spring Boot

Mòdul 1: Introducció a Spring Boot

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats