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
- El model de components de Spring Security 6
ConfiguracioSeguretat: la primera classe de.seguretat- El DSL de lambdes, apartat per apartat
authorizeHttpRequestsirequestMatchers- La regla d'or de l'ordre de les regles
- El mapa d'accessos de CicloUrbana
- Usuaris en memòria amb
InMemoryUserDetailsManager - Codificació de contrasenyes
- HTTP Basic i form login
- CSRF: què és i quan es pot desactivar
- Gestió de sessió:
STATELESS - Integrar la configuració CORS de 03-02
- Capçaleres de seguretat de la resposta
- Diverses cadenes amb
@OrderisecurityMatcher - Depurar la seguretat
- Errors Comuns i Consells
- Exercicis
- 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).
ConfiguracioSeguretat: la primera classe de .seguretat
ConfiguracioSeguretat: la primera classe de .seguretatpackage 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.
- 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 propisTres 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.
authorizeHttpRequests i requestMatchers
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.
- 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:
- Ordena d'específic a general, sempre. Rutes concretes primer, comodins amplis després.
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.- Acaba sempre amb
anyRequest().denyAll()oanyRequest().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).
- 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.
- Usuaris en memòria amb
InMemoryUserDetailsManager
InMemoryUserDetailsManagerPer 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 credencialsAquests 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.
- 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).
- 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.
- 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())).
- Gestió de sessió:
STATELESS
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:
- Escala horitzontalment sense esforç: qualsevol instància atén qualsevol petició, sense sessió apegalosa ni replicació. Importarà al mòdul 7.
- 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ó.
- No hi ha «tancar la sessió» al servidor: no hi ha res per invalidar. El problema es tracta a 05-04.
- 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:
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.
- 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.
- Diverses cadenes amb
@Order i securityMatcher
@Order i securityMatcherUn 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:
securityMatcherdecideix a quines peticions s'aplica la cadena;requestMatchers, dins d'authorizeHttpRequests, decideix quin permís cal. Confondre'ls és constant.- Només s'aplica la primera cadena que coincideix (05-01). Les altres no es consulten, encara que tinguin regles més específiques.
- El
@Ordermés baix guanya, i la cadena sensesecurityMatchercoincideix amb tot, així que ha de portar el@Ordermé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.
- 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: TRACEAmb 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
debugtambé capçaleres que poden contenir credencials. No s'han d'activar mai en producció. El seu lloc ésapplication-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/estacionsenGET: públic. Qualsevol escriptura sobre estacions:ADMIN.- Tot
/api/v1/lloguers/**: autenticat. /api/v1/bicicletes/**enGET: autenticat; escriptura:OPERARIoADMIN./swagger-ui/**i/v3/api-docs/**: públic només al perfildev.- 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
- Què és Spring Boot?
- Configuració del teu entorn de desenvolupament
- Creant la teva primera aplicació Spring Boot
- Entenent l'estructura del projecte
- L'arrencada i el cicle de vida de l'aplicació
Mòdul 2: Conceptes bàsics de Spring Boot
- Anotacions de Spring Boot
- Injecció de dependències a Spring Boot
- Àmbit i cicle de vida dels beans
- Configuració de Spring Boot
- Propietats de Spring Boot
- Autoconfiguració i starters per dins
Mòdul 3: Construint serveis web RESTful
- Introducció als serveis web RESTful
- Creant controladors REST
- Gestió dels mètodes HTTP
- Validació de dades d'entrada
- DTOs i mapatge entre capes
- Gestió d'excepcions a REST
- Documentar l'API amb OpenAPI
Mòdul 4: Accés a dades amb Spring Boot
- Introducció a Spring Data JPA
- Configuració de fonts de dades
- Creació d'entitats JPA
- Relacions entre entitats
- Ús de repositoris de Spring Data
- Mètodes de consulta a Spring Data JPA
- Transaccions i gestió de la persistència
- Migracions d'esquema amb Flyway
Mòdul 5: Seguretat a Spring Boot
- Introducció a Spring Security
- Configuració de Spring Security
- Autenticació i autorització d'usuaris
- Implementació d'autenticació JWT
- Seguretat a nivell de mètode i enduriment de l'API
Mòdul 6: Proves a Spring Boot
- Introducció a les proves
- Proves unitàries amb JUnit
- Simulació amb Mockito
- Proves d'integració
- Proves amb Testcontainers
Mòdul 7: Funcions avançades de Spring Boot
- Spring Boot Actuator
- Perfils de Spring Boot
- Tasques programades i execució asíncrona
- Spring Boot amb Docker
- Spring Boot i microserveis
- Comunicació entre serveis i tolerància a fallades
Mòdul 8: Desplegament d'aplicacions Spring Boot
- Introducció al desplegament
- Desplegant a Heroku
- Desplegant a AWS
- Desplegant a Kubernetes
- Integració i lliurament continus
Mòdul 9: Rendiment i monitoratge
- Ajust de rendiment
- Memòria cau amb Spring Cache
- Monitoratge amb Spring Boot Actuator
- Ús de Prometheus i Grafana
- Gestió de registres i logs
- Traçabilitat distribuïda
