CicloUrbana va acabar la lliçó anterior amb una política d'accessos correcta i unes claus ridícules: la Marta, el Lluís i l'Anna viuen en un InMemoryUserDetailsManager, les seves contrasenyes estan escrites al codi font, desapareixen a cada reinici i ningú no es pot donar d'alta. Mentrestant, a PostgreSQL hi ha una taula usuaris amb el correu, el nom, el tipus de tarifa i la data d'alta dels ciutadans de Ribalta que no té cap relació amb l'usuari que s'autentica. Són dos mons separats, i aquesta lliçó els uneix.
Ampliarem l'entitat Usuari de 04-03 amb credencials i rols mitjançant la migració V4, escriurem un UserDetailsService propi que carregui per correu des d'UsuariRepositori, crearem un UsuariAutenticat que conservi l'id —una decisió petita amb conseqüències enormes a 05-05—, obrirem l'endpoint de registre de ciutadans, aclarirem d'una vegada la confusió entre rols i autoritats, i farem que POST /api/v1/lloguers deixi de refiar-se de l'usuariId que li enviï el client. En acabar, la identitat de CicloUrbana serà real i persistent.
Advertiment. Totes les dades personals i contrasenyes d'aquesta lliçó són fictícies. Una gestió d'identitats real exigeix més del que hi cap aquí: verificació del correu, política de contrasenyes contrastada amb llistes de contrasenyes filtrades, recuperació segura, segon factor i compliment del RGPD. Res del que construïm no s'ha d'exposar a Internet sense la revisió d'un professional de seguretat. I els secrets mai no es versionen al repositori.
Contingut
- D'usuaris en memòria a usuaris reals
- Ampliar l'entitat
Usuariamb credencials i rols - La migració
V4__afegir_credencials_usuaris.sql UsuariAutenticat: unUserDetailspropiServeiDetallsUsuari: elUserDetailsServicede CicloUrbanaDaoAuthenticationProvider: què fa exactament- El flux complet amb
AuthenticationManager - Registre de ciutadans
- Rols i autoritats: el prefix
ROLE_i els permisos granulars - Jerarquia de rols amb
RoleHierarchy - Accedir a l'usuari autenticat des del controlador
- Respostes
401i403amb formatProblemDetail - Intents fallits i bloqueig de comptes
- Errors Comuns i Consells
- Exercicis
- D'usuaris en memòria a usuaris reals
El canvi consisteix a substituir una sola peça. Tota la resta —la cadena de filtres, les regles d'authorizeHttpRequests, el PasswordEncoder— continua igual:
| Peça | A 05-02 | A partir d'ara |
|---|---|---|
UserDetailsService |
InMemoryUserDetailsManager |
ServeiDetallsUsuari contra UsuariRepositori |
UserDetails |
User de Spring Security |
UsuariAutenticat propi, amb l'id |
| Origen de les dades | Un mapa en memòria | La taula usuaris de PostgreSQL |
| Alta d'usuaris | Recompilar i reiniciar | POST /api/v1/auth/registre |
| Rols | Fixos al codi | Taula usuari_rols |
Que n'hi hagi prou amb canviar una implementació és exactament l'objectiu del disseny de Spring Security que vam veure a 05-01: UserDetailsService és una interfície d'un sol mètode, i tot el que hi ha per damunt ignora d'on surten les dades.
- Ampliar l'entitat
Usuari amb credencials i rols
Usuari amb credencials i rolsL'entitat de 04-03 té identitat i dades personals, però res amb què autenticar. Li falten tres coses: amb què demostrar qui és (el resum de la contrasenya), què pot fer (els rols) i si continua en actiu —això últim ja ho teníem—. El correu tampoc no cal afegir-lo: la columna correu existeix des de V1 i ja és UNIQUE, així que és l'identificador d'inici de sessió natural.
package com.ciclourbana.usuaris;
public enum RolUsuari {
CIUTADA, // lloga bicicletes
OPERARI, // manté la flota
ADMIN // gestiona la xarxa i els usuaris
}@Entity
@Table(name = "usuaris")
public class Usuari extends EntitatAuditable {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "usuaris_seq")
@SequenceGenerator(name = "usuaris_seq", sequenceName = "usuaris_id_seq",
allocationSize = 50)
private Long id;
@Column(nullable = false, unique = true, length = 120)
private String correu; // identificador d'inici de sessió
/** Resum BCrypt amb prefix, mai la contrasenya. Exclòs de toString i equals. */
@Column(name = "contrasenya_hash", nullable = false, length = 100)
private String contrasenyaHash;
@Column(nullable = false)
private boolean actiu = true;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "usuari_rols", joinColumns = @JoinColumn(name = "usuari_id"))
@Column(name = "rol", nullable = false, length = 20)
@Enumerated(EnumType.STRING)
private Set<RolUsuari> rols = new HashSet<>();
// nom, tipusTarifa, dataAlta, versio i auditoria continuen igual (04-03)
}Quatre decisions que mereixen justificació:
@ElementCollection i no @ManyToMany amb una entitat Rol. Els rols de CicloUrbana són un conjunt fix i tancat de tres valors sense atributs propis ni cicle de vida: no hi ha res per administrar sobre «el rol OPERARI». Si algun dia fossin configurables des d'un tauler, amb descripció i permisos associats, llavors sí que caldria una entitat.
@Enumerated(EnumType.STRING), mai ORDINAL. És la regla de 04-03, i aquí és crítica: amb ORDINAL es desen 0, 1, 2, així que inserir un rol nou al mig de l'enumerat reassignaria en silenci els permisos de tots els usuaris.
fetch = EAGER, contra la regla general de 04-04. És l'excepció deliberada del projecte: els rols es necessiten sempre i immediatament en autenticar, i amb LAZY la càrrega passaria fora de la transacció del UserDetailsService, i provocaria una LazyInitializationException —recorda que open-in-view està a false des de 04-02—. Són com a molt tres valors per usuari. Si la col·lecció creixés, l'alternativa fina seria @EntityGraph.
contrasenyaHash amb length = 100. Un resum BCrypt ocupa 60 caràcters, i amb el prefix {bcrypt} en són 68; els 100 deixen marge per migrar a {argon2}. El nom del camp també importa: es diu contrasenyaHash, no contrasenya, perquè ningú no dubti de què conté. I ha de quedar fora de toString(), equals() i hashCode(), i no aparèixer mai en un DTO de resposta: els DTO de 03-05 ho garanteixen per construcció, perquè són llistes d'inclusió.
- La migració
V4__afegir_credencials_usuaris.sql
V4__afegir_credencials_usuaris.sqlAmb ddl-auto: validate des de 04-08, l'esquema només canvia per migració. Com que V1 ja va crear usuaris amb correu i actiu, aquesta migració només afegeix el que falta.
-- V4__afegir_credencials_usuaris.sql — credencials i rols de la xarxa de Ribalta.
-- 1) Resum de contrasenya. Nullable de moment: el pas 3 l'omple i el 4 la fixa.
ALTER TABLE usuaris ADD COLUMN contrasenya_hash VARCHAR(100);
-- 2) Rols. Clau primària composta: un usuari no pot tenir dues vegades el mateix rol.
CREATE TABLE usuari_rols (
usuari_id BIGINT NOT NULL,
rol VARCHAR(20) NOT NULL,
CONSTRAINT pk_usuari_rols PRIMARY KEY (usuari_id, rol),
CONSTRAINT fk_usuari_rols_usuari FOREIGN KEY (usuari_id)
REFERENCES usuaris (id) ON DELETE CASCADE,
CONSTRAINT ck_usuari_rols_rol CHECK (rol IN ('CIUTADA', 'OPERARI', 'ADMIN'))
);
CREATE INDEX ix_usuari_rols_usuari ON usuari_rols (usuari_id);
-- 3) Els usuaris preexistents queden sense credencial utilitzable i desactivats.
UPDATE usuaris SET contrasenya_hash = '{noop}SENSE-CREDENCIAL', actiu = FALSE
WHERE contrasenya_hash IS NULL;
INSERT INTO usuari_rols (usuari_id, rol)
SELECT id, 'CIUTADA' FROM usuaris;
-- 4) Ara sí, obligatòria.
ALTER TABLE usuaris ALTER COLUMN contrasenya_hash SET NOT NULL;Tres punts que s'aprenen amb aquesta migració:
El patró «nullable → omplir → NOT NULL» en tres passos és l'única manera d'afegir una columna obligatòria a una taula amb files: afegir-la directament com a NOT NULL falla, i posar-li un DEFAULT seria pitjor, perquè deixaria tots els usuaris amb la mateixa contrasenya.
ON DELETE CASCADE a la clau forana de rols és una de les poques cascades correctes en base de dades: un rol no té sentit sense el seu usuari, el criteri de domini de 04-04. I la restricció CHECK replica l'enumerat al motor: si algú insereix 'SUPERADMIN' amb SQL directe, la base de dades ho rebutja en lloc de deixar que Hibernate peti en llegir-ho.
Els usuaris preexistents queden desactivats a propòsit. És una decisió de seguretat, no un descuit: no existeix cap contrasenya que puguin haver triat, així que deixar-los actius amb un resum qualsevol seria pitjor. El literal {noop}SENSE-CREDENCIAL no correspon a cap contrasenya real, però a més actiu = FALSE fa que Spring Security els rebutgi fins i tot abans de comparar (apartat 6).
La Marta, el Lluís i l'Anna necessiten a més credencials per poder continuar provant. Això va en una migració a part, V4.1__usuaris_demo_ribalta.sql, col·locada a db/migration/dev amb el locations per entorn de 04-08: un UPDATE que els posa un resum BCrypt fictici i actiu = TRUE, més dos INSERT a usuari_rols que donen OPERARI al Lluís i ADMIN a l'Anna. No ha d'arribar mai a producció, i per això viu fora de db/migration.
UsuariAutenticat: un UserDetails propi
UsuariAutenticat: un UserDetails propiEl més ràpid seria que el UserDetailsService retornés el User de Spring Security. Funciona, i és el que fa la meitat dels tutorials. CicloUrbana no ho fa, per un motiu molt concret.
User desa només el nom d'usuari, el resum i les autoritats. Quan després, en un servei, necessitem saber quin usuari de la base de dades està fent la petició per comprovar que un lloguer és seu, només tindrem el correu, i caldrà anar a buscar l'id a la base de dades a cada petició. Amb un UserDetails propi, l'id viatja dins del SecurityContext:
package com.ciclourbana.seguretat;
public class UsuariAutenticat implements UserDetails {
private final Long idUsuari;
private final String correu;
private final String contrasenyaHash;
private final boolean actiu;
private final Collection<? extends GrantedAuthority> autoritats;
public UsuariAutenticat(Usuari usuari) { // còpia immutable de l'entitat
this.idUsuari = usuari.getId();
this.correu = usuari.getCorreu();
this.contrasenyaHash = usuari.getContrasenyaHash();
this.actiu = usuari.isActiu();
this.autoritats = usuari.getRols().stream()
.map(rol -> new SimpleGrantedAuthority("ROLE_" + rol.name()))
.toList();
}
/** La raó de ser d'aquesta classe: l'id sense consultar la base de dades. */
public Long getIdUsuari() { return idUsuari; }
@Override public String getUsername() { return correu; }
@Override public String getPassword() { return contrasenyaHash; }
@Override public boolean isEnabled() { return actiu; }
@Override public Collection<? extends GrantedAuthority> getAuthorities() { return autoritats; }
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return true; } // apartat 13
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public String toString() { // mai el resum
return "UsuariAutenticat{id=%d, correu='%s'}".formatted(idUsuari, correu);
}
}Quatre observacions:
El prefix ROLE_ s'afegeix aquí, en convertir l'enumerat en GrantedAuthority. A la base de dades es desa ADMIN; al context de seguretat hi viu ROLE_ADMIN. Concentrar aquesta conversió en un únic punt evita que el prefix aparegui a mitges per la resta del codi, que és la causa de la meitat dels 403 misteriosos.
És una còpia immutable, no l'entitat. Fer que Usuari implementés UserDetails és una drecera temptadora i una mala idea: obligaria l'entitat JPA a arrossegar mètodes de Spring Security, l'acoblaria al framework i —l'important— ficaria una entitat gestionada dins del SecurityContext, amb el seu EntityManager tancat i les seves col·leccions mandroses a punt de llançar LazyInitializationException en qualsevol punt. El domini no ha de saber que existeix Spring Security.
toString() no exposa el resum, perquè un log.debug("usuari={}", usuari) innocent seria una fuita. I isAccountNonLocked() retorna true avui, però és el punt exacte per on entrarà el bloqueig per intents fallits de l'apartat 13.
ServeiDetallsUsuari: el UserDetailsService de CicloUrbana
ServeiDetallsUsuari: el UserDetailsService de CicloUrbanapackage com.ciclourbana.seguretat;
@Service
public class ServeiDetallsUsuari implements UserDetailsService {
private static final Logger log = LoggerFactory.getLogger(ServeiDetallsUsuari.class);
private final UsuariRepositori usuariRepositori;
public ServeiDetallsUsuari(UsuariRepositori repo) { this.usuariRepositori = repo; }
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String correu) throws UsernameNotFoundException {
return usuariRepositori.findByCorreuIgnoreCase(correu)
.map(UsuariAutenticat::new)
.orElseThrow(() -> {
// Nivell DEBUG: en INFO, un atac d'enumeració ompliria el log
log.debug("Intent d'accés amb correu no registrat");
return new UsernameNotFoundException("Credencials no vàlides");
});
}
}A UsuariRepositori (04-05) n'hi ha prou amb dues consultes derivades: Optional<Usuari> findByCorreuIgnoreCase(String correu) i boolean existsByCorreuIgnoreCase(String correu).
Quatre detalls:
@Transactional(readOnly = true) és imprescindible: sense transacció activa, la col·lecció rols no es podria carregar encara que fos EAGER en alguns escenaris, i readOnly permet a Hibernate saltar-se la comprovació de canvis (04-07).
IgnoreCase. Els correus no distingeixen majúscules a la pràctica. Sense això, [email protected] seria un usuari diferent de [email protected] i el ciutadà no entendria per què no pot entrar. Ho complementarem normalitzant a minúscules en registrar.
El missatge de l'excepció no diu «el correu no existeix». És deliberat, i l'apartat següent explica per què.
Aquí no es comprova si l'usuari està actiu. Aquesta feina és de l'AuthenticationProvider, que sap distingir entre «no existeix», «està desactivat» i «la contrasenya no coincideix». El UserDetailsService només busca i retorna.
DaoAuthenticationProvider: què fa exactament
DaoAuthenticationProvider: què fa exactamentDaoAuthenticationProvider és l'AuthenticationProvider que combina un UserDetailsService amb un PasswordEncoder. Amb Spring Boot es configura sol: n'hi ha prou que existeixin tots dos beans al context. Declarar-lo explícitament, però, fa visible el que passa:
@Bean
AuthenticationProvider proveidorAutenticacio(ServeiDetallsUsuari detalls,
PasswordEncoder codificador) {
var proveidor = new DaoAuthenticationProvider();
proveidor.setUserDetailsService(detalls);
proveidor.setPasswordEncoder(codificador);
proveidor.setHideUserNotFoundExceptions(true); // amaga el motiu real: a propòsit
return proveidor;
}
/** Necessari a 05-04 per autenticar des de l'endpoint d'inici de sessió. */
@Bean
AuthenticationManager gestorAutenticacio(AuthenticationConfiguration configuracio)
throws Exception {
return configuracio.getAuthenticationManager();
}La seva feina, pas a pas:
- Crida
loadUserByUsernameamb el nom presentat. - Si no el troba, executa igualment una comparació de contrasenya contra un resum fictici. Això no és cap badada: és una defensa contra atacs de temporització. Si en no trobar l'usuari respongués immediatament, l'atacant mesuraria que la resposta arriba en 2 ms per a correus inexistents i en 90 ms per a correus reals —els 90 ms del BCrypt— i podria enumerar tots els correus registrats de la xarxa de Ribalta sense encertar ni una contrasenya.
- Comprova l'estat del compte amb els quatre booleans de
UserDetails, i llançaDisabledException,LockedException,AccountExpiredExceptionoCredentialsExpiredExceptionsegons correspongui. - Compara amb
passwordEncoder.matches(presentada, emmagatzemada), que aplica l'algorisme indicat pel prefix{bcrypt}i compara en temps constant. - Si el resum fa servir paràmetres obsolets i el codificador implementa
upgradeEncoding, pot recodificar la contrasenya al vol. I finalment retorna unUsernamePasswordAuthenticationTokenautenticat, amb l'UsuariAutenticatcom a principal, les autoritats i les credencials esborrades.
Per què s'amaguen els motius al client. Amb hideUserNotFoundExceptions a true, tant «aquest correu no existeix» com «la contrasenya no coincideix» surten com una única BadCredentialsException, i l'API respon sempre el mateix: 401 amb el text «Credencials no vàlides». Distingir-les seria un regal per a l'atacant: li permetria confirmar quins correus estan donats d'alta al servei municipal, cosa que a més és una dada personal. És incòmode per a l'usuari legítim i és la pràctica correcta.
El mateix criteri s'aplica al registre: no ha de respondre «aquest correu ja està registrat» de manera pública si això permet descobrir qui fa servir el servei. A CicloUrbana retornarem 409 perquè el registre és obert i l'usuari necessita saber que ja té compte, però és una decisió que en un servei sensible es prendria a l'inrevés (resposta genèrica més avís per correu).
- El flux complet amb
AuthenticationManager
AuthenticationManagersequenceDiagram
participant C as Client
participant F as Filtre d'autenticació
participant PM as ProviderManager
participant DAP as DaoAuthenticationProvider
participant SDU as ServeiDetallsUsuari
participant R as UsuariRepositori
participant PE as PasswordEncoder
C->>F: [email protected] + contrasenya
F->>PM: authenticate(token sense autenticar)
PM->>DAP: supports(UsernamePasswordAuthenticationToken)? sí
DAP->>SDU: loadUserByUsername("[email protected]")
SDU->>R: findByCorreuIgnoreCase(...)
R-->>SDU: Usuari + rols
SDU-->>DAP: UsuariAutenticat
DAP->>DAP: actiu? no bloquejat?
DAP->>PE: matches(presentada, "{bcrypt}$2a$10$...")
PE-->>DAP: true
DAP-->>PM: Authentication autenticat
PM-->>F: Authentication amb UsuariAutenticat
F->>F: SecurityContextHolder.setContext(...)
Si alguna cosa falla —usuari inexistent, contrasenya incorrecta, compte desactivat— el ProviderManager recull l'AuthenticationException, publica un esdeveniment de fallada (apartat 13) i la propaga. L'ExceptionTranslationFilter de 05-01 la tradueix en un 401 a través de l'AuthenticationEntryPoint.
- Registre de ciutadans
Amb l'entitat i el servei a punt, ja es pot obrir l'alta pública. És l'únic endpoint d'escriptura sense autenticació de tota l'API, així que mereix cura.
package com.ciclourbana.seguretat.dto;
public record RegistreRequest(
@NotBlank @Email @Size(max = 120) String correu,
@NotBlank @Size(max = 120) String nom,
@NotBlank @Size(min = 12, max = 72) String contrasenya) {}
public record UsuariRegistratResponse(Long id, String correu, String nom) {}El controlador és l'habitual des de 03-03: un @RestController sobre /api/v1/auth amb un @PostMapping("/registre") que rep @Valid @RequestBody RegistreRequest, delega en el servei i respon 201 Created amb la capçalera Location. Tota la substància és al servei:
@Service
public class ServeiRegistre {
private final UsuariRepositori usuariRepositori;
private final PasswordEncoder codificador;
private final Clock rellotge;
// constructor omès
@Transactional
public UsuariRegistratResponse registrarCiutada(RegistreRequest peticio) {
String correu = peticio.correu().trim().toLowerCase(Locale.ROOT);
if (usuariRepositori.existsByCorreuIgnoreCase(correu)) {
throw new ConflicteRecursException(
"Ja existeix un compte amb aquest correu", "CORREU_JA_REGISTRAT");
}
Usuari usuari = new Usuari();
usuari.setCorreu(correu);
usuari.setNom(peticio.nom().trim());
usuari.setContrasenyaHash(codificador.encode(peticio.contrasenya()));
usuari.setActiu(true);
usuari.setDataAlta(LocalDate.now(rellotge));
usuari.setRols(Set.of(RolUsuari.CIUTADA)); // mai des de la petició
Usuari desat = usuariRepositori.save(usuari);
return new UsuariRegistratResponse(desat.getId(), desat.getCorreu(),
desat.getNom());
}
}Cinc decisions de seguretat en vint línies:
El rol no ve de la petició: es fixa al servidor. RegistreRequest no té camp rols. Si en tingués, qualsevol es podria registrar com a ADMIN enviant {"rols":["ADMIN"]}. És l'atac de mass assignment, i els DTO de petició de 03-05 l'eviten per construcció: el que no és al DTO no hi pot arribar. Hi tornarem a 05-05.
La resposta no inclou el resum ni els rols. UsuariRegistratResponse és una llista d'inclusió amb tres camps.
El correu es normalitza a minúscules abans de comprovar i de desar, coherent amb l'IgnoreCase del repositori. La comprovació de duplicat té una condició de cursa —dos registres simultanis amb el mateix correu poden passar tots dos l'existsBy—, i per això la restricció UNIQUE de la base de dades és la garantia real. Si salta, Hibernate llança DataIntegrityViolationException, que convé traduir també a 409 a GestorGlobalExcepcions. És el mateix raonament de l'índex únic parcial de 04-08: la validació a Java és per donar un bon missatge; el motor és qui garanteix.
El mínim de 12 caràcters és un mínim, no una política. Una política de contrasenyes seriosa comprova a més la contrasenya contra llistes de contrasenyes filtrades —el servei Pwned Passwords permet fer-ho sense enviar la contrasenya— i evita les regles de composició obsoletes (obligar a un símbol i un número produeix Password1! una vegada i una altra). El màxim de 72 no és arbitrari: BCrypt trunca silenciosament a 72 bytes, així que acceptar-ne més donaria una falsa sensació de seguretat.
Falta una cosa important i cal dir-ho: aquest registre no verifica el correu. Qualsevol es pot donar d'alta amb el correu d'una altra persona. Un servei real envia un enllaç de confirmació amb un token d'un sol ús i manté el compte inactiu fins que es confirma. Queda fora de l'abast del curs, però no de l'abast d'un sistema en producció.
- Rols i autoritats: el prefix
ROLE_ i els permisos granulars
ROLE_ i els permisos granularsÉs la confusió més estesa de Spring Security, i s'aclareix amb una sola idea: només existeixen autoritats. Un rol és una autoritat el nom de la qual comença per ROLE_.
| Expressió | Autoritat que busca | Equivalent |
|---|---|---|
hasRole("ADMIN") |
ROLE_ADMIN |
hasAuthority("ROLE_ADMIN") |
hasAuthority("ADMIN") |
ADMIN |
No és el mateix que l'anterior |
hasRole("ROLE_ADMIN") |
ROLE_ROLE_ADMIN |
Error: llança excepció en arrencar |
hasAuthority("estacions:escriure") |
estacions:escriure |
Permís granular |
La regla mecànica: hasRole afegeix el prefix, hasAuthority no l'afegeix. El mateix passa en crear usuaris (.roles(...) davant d'.authorities(...)) i al RoleHierarchy de l'apartat següent, on sí que s'escriu el prefix perquè es treballa amb autoritats.
Rols davant de permisos. Els rols agrupen persones; els permisos descriuen accions. Un sistema amb només rols acaba ple de regles com hasAnyRole("OPERARI","ADMIN","SUPERVISOR","MANTENIMENT"), que cal revisar sencera cada vegada que apareix un perfil nou:
| Rols | Permisos granulars | |
|---|---|---|
| Exemple | ROLE_OPERARI |
bicicletes:escriure, estacions:llegir |
| Llegibilitat de la regla | Alta | Mitjana |
| Afegir un perfil nou | Tocar totes les regles | Només assignar permisos |
| Auditoria «qui pot fer X?» | Difícil | Directa |
| Complexitat inicial | Baixa | Alta |
CicloUrbana fa servir rols, i és la decisió correcta avui: tres perfils estables i ben delimitats no justifiquen la complexitat d'un model de permisos. Però convé saber que el canvi no és traumàtic si UsuariAutenticat és qui tradueix el model de domini a autoritats: n'hi hauria prou que cada RolUsuari aportés el seu conjunt de permisos i retornar tots dos. El senyal que ha arribat el moment és concret: quan aparegui el quart rol, o quan dos perfils necessitin solapaments parcials de permisos.
- Jerarquia de rols amb
RoleHierarchy
RoleHierarchyA 05-02 vam escriure hasAnyRole("OPERARI", "ADMIN") dues vegades, i vam anotar que era un símptoma. La causa és que un ADMIN de CicloUrbana hauria de poder fer tot el que fa un OPERARI, i això no està dit enlloc: cal repetir-ho regla a regla, i n'hi ha prou d'oblidar-ho una vegada perquè un administrador es trobi un 403 inexplicable.
@Bean
static RoleHierarchy jerarquiaRols() {
return RoleHierarchyImpl.withDefaultRolePrefix()
.role("ADMIN").implies("OPERARI")
.role("OPERARI").implies("CIUTADA")
.build();
}
/** Necessari perquè la jerarquia s'apliqui també a la seguretat de mètode (05-05). */
@Bean
static MethodSecurityExpressionHandler gestorExpressions(RoleHierarchy jerarquia) {
var gestor = new DefaultMethodSecurityExpressionHandler();
gestor.setRoleHierarchy(jerarquia);
return gestor;
}ADMIN > OPERARI > CIUTADA. A partir d'aquí, una regla hasRole("OPERARI") la satisfà també un ADMIN, i les regles de 05-02 se simplifiquen:
.requestMatchers("/api/v1/incidencies/**").hasRole("OPERARI") // ADMIN inclòs
.requestMatchers("/api/v1/bicicletes/**").hasRole("OPERARI") // ADMIN inclòsDos advertiments. Els beans són static perquè s'han de crear molt aviat al cicle de vida, abans que la infraestructura de seguretat que els consumeix; si no, Spring avisa d'una inicialització prematura de beans. I la jerarquia es defineix amb el prefix per defecte, és a dir, treballa sobre ROLE_ADMIN i ROLE_OPERARI: withDefaultRolePrefix() l'afegeix per tu.
Quan no fer servir jerarquia. Només té sentit quan els perfils són realment acumulatius. Si en el futur CicloUrbana tingués un rol AUDITOR que pot llegir-ho tot però no escriure res, no encaixaria a la cadena: no és «més» ni «menys» que els altres, és diferent. Forçar una jerarquia sobre rols que no la tenen produeix permisos concedits per accident, que és el contrari del que busquem.
- Accedir a l'usuari autenticat des del controlador
Tres formes, i no són equivalents:
| Forma | Com s'obté | Avantatges | Inconvenients |
|---|---|---|---|
SecurityContextHolder |
Estàtic, des de qualsevol punt | Funciona en serveis i utilitats | Acobla el codi a Spring Security; difícil de provar; falla en fils @Async (07-03) |
Paràmetre Authentication |
Spring l'injecta al mètode | Explícit, fàcil de simular en proves | Cal fer cast del principal |
@AuthenticationPrincipal |
Injecta directament el principal tipat | Llegible, tipat i sense cast | Requereix un UserDetails propi per ser útil |
// 1. Paràmetre Authentication: correcte, però obliga a un cast
public List<LloguerResponse> meus(Authentication autenticacio) {
var usuari = (UsuariAutenticat) autenticacio.getPrincipal();
return lloguerService.cercarPerUsuari(usuari.getIdUsuari());
}
// 2. @AuthenticationPrincipal: la forma preferida a CicloUrbana
@GetMapping("/meus")
public List<LloguerResponse> meus(@AuthenticationPrincipal UsuariAutenticat usuari) {
return lloguerService.cercarPerUsuari(usuari.getIdUsuari());
}Aquí es cobra la decisió de l'apartat 4. Amb el User estàndard, la tercera opció donaria un objecte sense id i caldria consultar la base de dades per correu a cada petició. Amb UsuariAutenticat, l'id ja hi és.
El canvi important: POST /api/v1/lloguers
Des de 03-03, iniciar un lloguer rebia l'usuariId al cos:
Això és ara un forat de seguretat de manual. La Marta pot iniciar lloguers a nom de qualsevol ciutadà de Ribalta només canviant un número: els càrrecs anirien al compte d'un altre. El servidor no ha de preguntar qui és el client quan ja ho sap.
public record IniciarLloguerRequest(@NotNull @Positive Long bicicletaId) {} // sense usuariId
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<LloguerResponse> iniciar(
@Valid @RequestBody IniciarLloguerRequest peticio,
@AuthenticationPrincipal UsuariAutenticat usuari) {
Lloguer lloguer = lloguerService.iniciar(usuari.getIdUsuari(), peticio.bicicletaId());
URI ubicacio = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(lloguer.getId()).toUri();
return ResponseEntity.created(ubicacio).body(lloguerMapper.aResposta(lloguer));
}La regla general, i és de les més valuoses del mòdul: la identitat de l'usuari no s'accepta mai del client. Es dedueix de la credencial. Tot camp d'una petició que digui «qui soc» és un vector de suplantació. El mateix val per a POST /api/v1/lloguers/{id}/finalitzar, encara que allà falta a més comprovar que aquell lloguer és d'aquell usuari: és autorització sobre la dada concreta, i cap regla per URL no ho pot expressar. És el tema de 05-05.
- Respostes
401 i 403 amb format ProblemDetail
401 i 403 amb format ProblemDetailA 05-01 vam veure el problema: les fallades de seguretat passen als filtres, abans del DispatcherServlet, així que el @RestControllerAdvice de 03-06 no les veu i l'API retorna dos formats d'error diferents. Un client que sap llegir ProblemDetail es troba de sobte amb una altra cosa.
La solució són dos objectes que es connecten a l'ExceptionTranslationFilter:
package com.ciclourbana.seguretat;
@Component
public class PuntEntradaNoAutenticat implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper; // constructor omès
@Override
public void commence(HttpServletRequest peticio, HttpServletResponse resposta,
AuthenticationException excepcio) throws IOException {
ProblemDetail problema = ProblemDetail.forStatusAndDetail(
HttpStatus.UNAUTHORIZED, "Cal autenticació per a aquesta operació");
problema.setType(URI.create("https://api.ciclourbana.example/errors/no-autenticat"));
problema.setTitle("No autenticat");
problema.setProperty("codi", "NO_AUTENTICAT");
problema.setProperty("rastre", MDC.get(FiltreRastreig.CLAU_MDC)); // rastreig de 03-06
resposta.setStatus(HttpStatus.UNAUTHORIZED.value());
resposta.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
objectMapper.writeValue(resposta.getOutputStream(), problema);
}
}L'AccessDeniedHandler és idèntic tret de l'estat 403, el títol «Accés denegat» i el codi SENSE_PERMIS. Tots dos es registren a la cadena de 05-02:
.exceptionHandling(ex -> ex
.authenticationEntryPoint(puntEntradaNoAutenticat)
.accessDeniedHandler(gestorAccesDenegat))Tres coses importants. No es diu mai per què va fallar: ni «contrasenya incorrecta», ni «usuari desactivat», ni «et falta el rol ADMIN». El missatge genèric evita regalar informació, i el motiu real va al log del servidor. Es reaprofita MDC.get(FiltreRastreig.CLAU_MDC) de 03-06, de manera que el ciutadà que truca al suport amb l'identificador a3f5c9e1 permet localitzar la petició exacta —sempre que FiltreRastreig estigui registrat abans que la seguretat a la cadena de filtres, cosa que s'aconsegueix donant-li un @Order baix—. I el tipus de contingut és application/problem+json, el mateix de l'RFC 7807, perquè el client apliqui el mateix tractament a tots els errors.
Resultat, ja coherent amb la resta de l'API:
{ "type": "https://api.ciclourbana.example/errors/no-autenticat",
"title": "No autenticat", "status": 401,
"detail": "Cal autenticació per a aquesta operació",
"codi": "NO_AUTENTICAT", "rastre": "a3f5c9e1" }
- Intents fallits i bloqueig de comptes
Sense límit d'intents, un atacant pot provar contrasenyes indefinidament. BCrypt ho fa lent —uns 90 ms per intent amb cost 10—, cosa que ja descarta la força bruta massiva, però no impedeix provar les cent contrasenyes més freqüents contra milers de correus, que és com es comprometen la majoria dels comptes reals.
Spring Security publica esdeveniments d'autenticació que permeten reaccionar sense tocar el flux:
@Component
public class EscoltaEsdevenimentsAutenticacio {
private static final Logger log =
LoggerFactory.getLogger(EscoltaEsdevenimentsAutenticacio.class);
private final ServeiIntentsFallits intents; // constructor omès
@EventListener
public void alFallar(AuthenticationFailureBadCredentialsEvent esdeveniment) {
String correu = esdeveniment.getAuthentication().getName();
int fallades = intents.registrarFallada(correu);
log.warn("Autenticació fallida per a '{}' (intent {})", correu, fallades);
// MAI: log.warn("contrasenya={}", esdeveniment.getAuthentication().getCredentials())
}
@EventListener
public void alEncertar(AuthenticationSuccessEvent e) { intents.netejar(e.getAuthentication().getName()); }
}AuthenticationFailureBadCredentialsEvent és una de les subclasses d'AbstractAuthenticationFailureEvent; n'hi ha d'altres per a compte desactivat, bloquejat o credencials caducades, i es poden escoltar totes alhora fent servir la classe pare.
Amb el comptador disponible, isAccountNonLocked() d'UsuariAutenticat deixa de retornar true fix i consulta l'estat. Quatre decisions de disseny:
- Bloqueig temporal, no permanent. Bloquejar per sempre converteix l'atac en una denegació de servei: n'hi hauria prou de fallar cinc vegades contra el correu d'un ciutadà per deixar-lo fora. L'habitual són de 5 a 15 minuts, o millor un retard progressiu —1 s després de la tercera fallada, 2 s després de la quarta, 4 s després de la cinquena—, que frena l'atacant sense castigar qui s'equivoca en teclejar.
- Comptar també per IP d'origen, no només per compte, per detectar l'atac distribuït contra molts correus. I el magatzem ha de ser compartit si hi ha diverses instàncies: un
ConcurrentHashMapen memòria deixa de servir tan bon punt l'aplicació escala (la memòria cau distribuïda es tracta a 09-02).
En un sistema real això es complementa amb limitació de taxa a la passarel·la (05-05) i amb l'alerta a l'equip de seguretat davant de pics de fallades (09-05).
Errors Comuns i Consells
Fer que Usuari implementi UserDetails. Acobla l'entitat JPA al framework i fica un objecte gestionat, amb col·leccions mandroses, dins del SecurityContext. Fes servir una còpia immutable.
Desar els rols amb @Enumerated(ORDINAL). Inserir un valor nou al mig de l'enumerat reassigna en silenci els permisos de tots els usuaris.
Desar el rol amb el prefix ROLE_ a la base de dades. Duplica el prefix en construir les autoritats. Desa ADMIN i afegeix ROLE_ en un únic punt.
Deixar rols com a LAZY sense @EntityGraph. Amb open-in-view: false (04-02), la càrrega fora de la transacció del UserDetailsService produeix LazyInitializationException al punt més incòmode possible.
Distingir a la resposta entre «l'usuari no existeix» i «la contrasenya és incorrecta». Permet enumerar els correus registrats. Respon sempre el mateix.
Acceptar el rol o l'id d'usuari al DTO de registre o de lloguer. És mass assignment i suplantació. La identitat i els privilegis els fixa el servidor.
Codificar la contrasenya al controlador, o en dos llocs diferents. L'encode va al servei, dins de la transacció i en un únic punt; si es duplica, tard o d'hora un dels dos desarà la contrasenya en clar. I no registris mai la contrasenya ni el resum en un log, ni en DEBUG: els logs es copien, s'envien a sistemes externs i es conserven anys.
Consell: normalitza el correu en registrar i en cercar. Minúscules i trim als dos extrems eviten comptes duplicats que només es diferencien en majúscules. I recorda que la garantia real és la restricció UNIQUE de la base de dades, no l'existsBy: l'existsBy dona un bon missatge, però el motor és qui impedeix el duplicat sota concurrència.
Exercicis
Exercici 1
Escriu el UserDetailsService de CicloUrbana i l'UsuariAutenticat corresponent, partint d'una entitat Usuari amb correu, contrasenyaHash, actiu i Set<RolUsuari> rols. Justifica: on s'afegeix el prefix ROLE_, per què no es retorna l'entitat, quina anotació transaccional porta el mètode i quin missatge d'error s'exposa al client.
Exercici 2
Un ciutadà informa que va canviar el seu correu a [email protected] des del tauler d'administració i ara no pot entrar, mentre que un company, amb el mateix canvi, sí que hi entra. Analitza les causes possibles i proposa una solució completa que inclogui codi, migració i prevenció.
Exercici 3
Dissenya l'endpoint PATCH /api/v1/usuaris/{id}/rols, que permet a un ADMIN assignar el rol OPERARI a un ciutadà. Enumera totes les comprovacions de seguretat necessàries i escriu el DTO, el controlador i el servei.
Solucions
Solució 1
El codi és el dels apartats 4 i 5. Les quatre justificacions:
On s'afegeix ROLE_: al constructor d'UsuariAutenticat, en mapar RolUsuari a SimpleGrantedAuthority. A la base de dades es desa ADMIN, sense prefix, perquè és una dada de domini i no una convenció de Spring Security. Concentrar-ho en un punt és el que evita els ROLE_ROLE_ i els 403 inexplicables.
Per què no es retorna l'entitat: perquè acoblaria el domini al framework, obligaria Usuari a implementar set mètodes aliens a la seva responsabilitat i, sobretot, ficaria una entitat gestionada al SecurityContext. Amb open-in-view: false, qualsevol accés posterior a una relació mandrosa des del context de seguretat llançaria LazyInitializationException lluny de l'origen del problema.
Anotació transaccional: @Transactional(readOnly = true). Garanteix una sessió activa per materialitzar els rols i permet a Hibernate ometre la comprovació de canvis (04-07).
Missatge al client: sempre el mateix —«Credencials no vàlides»— tant si el correu no existeix com si la contrasenya falla, per no permetre enumerar els usuaris de la xarxa. El motiu real, al log del servidor a nivell DEBUG.
Solució 2
Causa arrel: el correu es va desar tal qual, amb majúscules. Si loadUserByUsername fes servir findByCorreu sense IgnoreCase, la Marta hauria d'escriure exactament [email protected] per entrar. Que al seu company li funcioni indica que ell el va escriure en minúscules o que el seu client el normalitza.
Causa secundària possible: duplicat. Si el canvi es va fer amb un INSERT en lloc d'un UPDATE, hi pot haver dues files, [email protected] i [email protected]. La restricció UNIQUE de V1 no ho impedeix, perquè a PostgreSQL distingeix majúscules.
Solució completa, en tres fronts.
1. Consulta insensible a majúscules —ja és a l'apartat 5— amb findByCorreuIgnoreCase, que genera WHERE upper(correu) = upper(?).
2. Normalització en tota escriptura, al servei: correu.trim().toLowerCase(Locale.ROOT) en registrar i en modificar. Locale.ROOT no és un detall menor: amb la configuració regional turca, toLowerCase() converteix la I en ı i produiria un correu diferent.
3. Migració que arregla les dades i preveu la reaparició:
-- V5__normalitzar_correus_usuaris.sql
-- Falla explícitament si ja hi ha duplicats: cal resoldre'ls a mà
DO $$
DECLARE duplicats INT;
BEGIN
SELECT COUNT(*) INTO duplicats FROM (SELECT lower(correu) FROM usuaris
GROUP BY lower(correu) HAVING COUNT(*) > 1) d;
IF duplicats > 0 THEN
RAISE EXCEPTION 'Hi ha % correus duplicats per majúscules', duplicats;
END IF;
END $$;
UPDATE usuaris SET correu = lower(trim(correu)) WHERE correu <> lower(trim(correu));
-- Prevenció definitiva: l'índex únic opera sobre la forma normalitzada
ALTER TABLE usuaris DROP CONSTRAINT uk_usuaris_correu;
CREATE UNIQUE INDEX uk_usuaris_correu ON usuaris (lower(correu));L'índex únic funcional és la solució definitiva, perquè fa impossible el duplicat sigui quin sigui el codi que s'escrigui: la garantia torna a estar al motor i no a la disciplina dels desenvolupadors. I el bloc DO que avorta si ja hi ha duplicats és bona pràctica de migració: és preferible que la migració falli abans que un UPDATE provoqui una violació de restricció a mitges.
Solució 3
Comprovacions necessàries, en ordre:
- Autenticació: qui crida ha d'estar autenticat →
401si no. - Autorització per rol: només
ADMIN→403. Ja ho cobreixrequestMatchers("/api/v1/usuaris/**").hasRole("ADMIN")de 05-02. - Existència de l'usuari destinatari →
404. - Validació del rol rebut: ha de ser un valor de l'enumerat. Bean Validation i el tipus
RolUsuariho garanteixen; un valor desconegut produeix400. - Regla de negoci: ningú no es concedeix privilegis a si mateix. Un
ADMINno ha de poder modificar els seus propis rols, perquè elimina el control de quatre ulls i facilita l'escalada si el seu compte es veu compromès. - Regla de negoci: no deixar el sistema sense administradors. Treure l'últim
ADMINdeixa la xarxa ingovernable. - Auditoria obligatòria: qui va canviar quins rols a qui i quan. És un canvi de privilegis.
El controlador és un @PatchMapping("/{id}/rols") que rep l'@Valid @RequestBody ActualitzarRolsRequest, el @PathVariable i l'@AuthenticationPrincipal UsuariAutenticat admin, i delega en el servei, on hi ha tota la lògica:
@Transactional
public UsuariResponse actualitzarRols(Long id, Set<RolUsuari> nousRols,
UsuariAutenticat admin) {
if (id.equals(admin.getIdUsuari())) {
throw new ReglaNegociException(
"Un administrador no pot modificar els seus propis rols", "AUTOASSIGNACIO");
}
Usuari usuari = usuariRepositori.findById(id)
.orElseThrow(() -> new RecursNoTrobatException("Usuari", id));
if (usuari.getRols().contains(RolUsuari.ADMIN)
&& !nousRols.contains(RolUsuari.ADMIN)
&& usuariRepositori.comptarPerRol(RolUsuari.ADMIN) <= 1) {
throw new ReglaNegociException(
"No es pot eliminar l'últim administrador", "ULTIM_ADMIN");
}
Set<RolUsuari> anteriors = Set.copyOf(usuari.getRols());
usuari.setRols(nousRols);
log.warn("CANVI DE ROLS: admin={} usuariAfectat={} abans={} despres={}",
admin.getIdUsuari(), id, anteriors, nousRols);
return usuariMapper.aResposta(usuari);
}Tres apunts. El log és WARN a propòsit: un canvi de privilegis no és rutina i ha de destacar en la revisió de logs (09-05). La comprovació de l'últim administrador té una condició de cursa —dues peticions simultànies podrien treure els dos últims—, resoluble amb el bloqueig pessimista de 04-07 o amb una restricció a la base de dades. I l'usuari afectat continuarà tenint els seus rols antics fins que renovi la credencial: amb sessió, fins que torni a entrar; amb JWT, fins que caduqui el token. És una limitació important del model sense estat i la tractarem a 05-04.
Conclusió
La identitat de CicloUrbana ja és real. Has ampliat l'entitat Usuari de 04-03 amb contrasenyaHash, l'actiu que ja tenia i un Set<RolUsuari> mapat amb @ElementCollection i @Enumerated(STRING), justificant cada decisió: per què un enumerat i no una entitat Rol, per què EAGER és aquí l'excepció correcta a la regla de 04-04, i per què el camp es diu contrasenyaHash i queda fora de toString. La migració V4__afegir_credencials_usuaris.sql ho ha portat a l'esquema amb el patró de tres passos «nullable → omplir → NOT NULL», una taula usuari_rols amb clau composta, ON DELETE CASCADE i una restricció CHECK que replica l'enumerat al motor, i ha deixat desactivats els usuaris preexistents per decisió de seguretat.
Has escrit ServeiDetallsUsuari, que carrega per correu des d'UsuariRepositori amb findByCorreuIgnoreCase dins d'una transacció de només lectura, i UsuariAutenticat, la còpia immutable que implementa UserDetails conservant l'id —una decisió de tres línies que evita una consulta per petició i que a 05-05 permetrà escriure principal.idUsuari en una expressió de seguretat—. Entens exactament què fa el DaoAuthenticationProvider: busca, compara amb PasswordEncoder, comprova els quatre estats del compte i calcula un resum fins i tot quan l'usuari no existeix, perquè respondre ràpid delataria quins correus estan registrats a la xarxa municipal. I saps per què totes les fallades d'autenticació es responen amb un únic missatge genèric.
Has obert el registre de ciutadans amb cinc decisions de seguretat en vint línies: el rol es fixa al servidor i no viatja al DTO —i s'evita el mass assignment—, la resposta és una llista d'inclusió sense resum ni rols, el correu es normalitza, la garantia d'unicitat la dona la restricció de la base de dades i no l'existsBy, i el límit de 72 caràcters respon a un detall real de BCrypt; tot això amb l'advertiment exprés que falta la verificació del correu, que cap servei real no pot ometre. Has aclarit que només existeixen autoritats i que un rol és una autoritat amb prefix ROLE_, has comparat rols i permisos granulars sabent quan tocarà migrar, i has simplificat les regles de 05-02 amb RoleHierarchy i la jerarquia ADMIN > OPERARI > CIUTADA, sense forçar-la sobre rols que no siguin acumulatius.
I has fet el canvi més important de la lliçó: POST /api/v1/lloguers ja no accepta l'usuariId del client, sinó que el dedueix de l'@AuthenticationPrincipal. La regla que ho resumeix tot: la identitat no s'accepta mai del client, es dedueix de la credencial. Els errors de seguretat parlen per fi el mateix idioma que la resta de l'API, amb AuthenticationEntryPoint i AccessDeniedHandler propis que retornen ProblemDetail amb el seu codi i el seu rastre del MDC de 03-06; i coneixes l'esquema de control d'intents fallits amb els esdeveniments d'autenticació i les seves quatre cauteles.
Queda un problema pràctic que l'app mòbil de Ribalta nota tan bon punt es connecta: amb HTTP Basic, el client ha de desar la contrasenya del ciutadà i enviar-la a cadascuna de les peticions. Cada consulta del mapa d'estacions arrossega la credencial més valuosa de l'usuari per la xarxa, i cadascuna obliga el servidor a calcular un BCrypt de 90 ms. No hi ha manera de fer caducar l'accés sense canviar la contrasenya, ni de concedir permisos limitats a una integració. A 05-04, Implementació d'Autenticació JWT, ho resoldrem: la contrasenya s'enviarà una sola vegada a POST /api/v1/auth/login i a canvi el servidor emetrà un token signat i caducable que el client presentarà a la capçalera Authorization: Bearer. Veurem l'anatomia d'un JWT camp a camp, l'implementarem amb JJWT i un FiltreAutenticacioJwt, hi afegirem tokens de refresc amb rotació i revocació en base de dades, i afrontarem amb honestedat les dues preguntes incòmodes del model sense estat: on desa el token el client i què significa realment tancar la sessió.
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
