In un precedente articolo, ti ho mostrato come realizzare un servizio di API in Spring Boot, ma niente a riguardo della sicurezza, che vedrai in questo articolo.
Vedrai come mettere in sicurezza un’applicazione web Spring Boot utilizzando Spring Security e lo standard JWT per l’autenticazione e l’autorizzazione delle API REST.
Prima di iniziare, qualora non lo sapessi, ti spiego brevemente cos’è un JWT (Json Web Token) e come questo viene utilizzato nelle applicazioni web.
Un token con lo standard JWT ha una rappresentazione JSON e può contenere informazioni personalizzate. La sua struttura è composta da un header, un payload (un set di informazioni chiamati Claims) e una signature. Un JWT infatti, può essere firmato tramite un algoritmo di cifratura come SHA256.
L’integrazione nelle applicazioni segue il flusso:
- Il client si autentica, attraverso ad esempio una API di login, con username e password.
- Il server controlla che l'utente esista (ad esempio su db) e la password sia corretta. Se è tutto ok, crea un token JWT con una firma tramite una secret key. A quel punto manda nella response della login (body o header) il token.
- Il client acquisisce il token, e per ogni chiamata REST che effettua al server, manda il token nell'header, solitamente nell'header con chiave Authorization usando le schema Bearer, poiché questo header evita problemi con CORS (Cross-Origin Resource Sharing).
- Il server, quando riceve una richiesta dal client, decodifica il token, esamina innanzitutto la signature per capire se è valida, successivamente analizza altri campi del payload come la data di scadenza del token, il ruolo dell'utente, etc. Se i check sono superati, il server mostrerà la response dell'API, altrimenti manderà un HTTP Status Response 403.
Fatta questa premessa procediamo all’implementazione vera e proprio.
Per questo tutorial prendiamo il codice utilizzato per il servizio REST creato nel precedente articolo e integriamo l’autenticazione.
Come primo step aggiungiamo le dipendenze che ci servono:
- spring-security-web
- spring-security-core
- spring-security-config
- jjwt
- spring-boot-starter-validation
Successivamente all’interno del file application.properties aggiungiamo due proprieta:
saverioriotto.app.jwtSecret= sRSecretKey
saverioriotto.app.jwtExpirationMs= 86400000
Per questo tutorial prendiamo in considerazione tre tabelle nel database: utenti, ruoli e utente_ruoli per una relazione molti-a-molti ed un enum, RuoloEnum.java, con tre ruoli predefiniti
Nel package entities, creiamo Utente.java e Ruolo.java
@Entity
@Table( name = "utenti",
uniqueConstraints = {
@UniqueConstraint(columnNames = "username"),
@UniqueConstraint(columnNames = "email")
})
public class Utente {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@NotBlank
@Size(max = 20)
private String username;
@NotBlank
@Size(max = 50)
@Email
private String email;
@NotBlank
@Size(max = 120)
private String password;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable( name = "utente_ruoli",
joinColumns = @JoinColumn(name = "id_utente"),
inverseJoinColumns = @JoinColumn(name = "id_ruolo"))
private Set roles = new HashSet<>();
public Utente() {
}
public Utente(String username, String email, String password) {
this.username = username;
this.email = email;
this.password = password;
}
//getter e setter
}
@Entity
@Table(name = "ruoli")
public class Ruolo {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Integer id;
@Enumerated(EnumType.STRING)
@Column(length = 20)
private RuoloEnum name;
public Ruolo() {
}
public Ruolo(RuoloEnum name) {
this.name = name;
}
//getter e setter
public enum RuoloEnum {
UTENTE,
MODERATORE,
ADMIN,
}
Ora, ogni modello sopra ha bisogno di un repository per la persistenza e l'accesso ai dati. Nel package repositories, creiamo due repository, UtenteRepository e RuoloRepository, che estendono JpaRepository per ereditare metodi di ricerca qualora servissero.
@Repository
public interface UtenteRepository extends JpaRepository {
@Query(value = "SELECT * FROM utenti WHERE username LIKE %?1%", nativeQuery = true)
Optional cercaPerUsername(String username);
@Query(value = "SELECT * FROM utenti WHERE email LIKE %?1%", nativeQuery = true)
Optional cercaPerEmail(String email);
}
@Repository
public interface RuoloRepository extends JpaRepository {
@Query(value = "SELECT * FROM ruoli WHERE name LIKE %?1%", nativeQuery = true)
Optional cercaPerNome(RuoloEnum name);
@Query(value = "SELECT * FROM ruoli WHERE name LIKE %?1%", nativeQuery = true)
List cercaPerNomeList(RuoloEnum name);
}
Rappresenta la classe di utilità per la gestione del token JWT.
@Component
public class JwtUtils {
private static final Logger logger = Logger.getLogger(JwtUtils.class.getName());
@Value("${saverioriotto.app.jwtSecret}")
private String jwtSecret;
@Value("${saverioriotto.app.jwtExpirationMs}")
private int jwtExpirationMs;
public String generateJwtToken(Authentication authentication) {
DettagliUtente userPrincipal = (DettagliUtente) authentication.getPrincipal();
return Jwts.builder()
.setSubject((userPrincipal.getUsername()))
.setIssuedAt(new Date())
.setExpiration(new Date((new Date()).getTime() + jwtExpirationMs))
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact();
}
public String generateJwtToken(String username) {
return Jwts.builder()
.setSubject((username))
.setIssuedAt(new Date())
.setExpiration(new Date((new Date()).getTime() + jwtExpirationMs))
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact();
}
public String getUserNameFromJwtToken(String token) {
return Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody().getSubject();
}
public boolean validateJwtToken(String authToken) {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
return true;
} catch (MalformedJwtException e) {
logger.log(Level.SEVERE,"Invalid JWT token: {}", e.getMessage());
} catch (ExpiredJwtException e) {
logger.log(Level.SEVERE,"JWT token is expired: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
logger.log(Level.SEVERE,"JWT token is unsupported: {}", e.getMessage());
} catch (IllegalArgumentException e) {
logger.log(Level.SEVERE,"JWT claims string is empty: {}", e.getMessage());
}
return false;
}
}
Rappresenta il filtro di autorizzazione che viene attivato automaticamente quando viene ricevuta una richiesta Http esterna.
public class AuthTokenFilter extends OncePerRequestFilter {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private DettagliUtenteService userDetailsService;
private static final Logger logger = Logger.getLogger(AuthTokenFilter.class.getName());
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String jwt = parseJwt(request);
if (jwt != null && jwtUtils.validateJwtToken(jwt)) {
String username = jwtUtils.getUserNameFromJwtToken(jwt);
DettagliUtente userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
logger.log(Level.SEVERE, "Cannot set user authentication: {}", e);
}
filterChain.doFilter(request, response);
}
private String parseJwt(HttpServletRequest request) {
String headerAuth = request.getHeader("Authorization");
if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
return headerAuth.substring(7, headerAuth.length());
}
return null;
}
}
Per implementare Spring Security con JWT dobbiamo prendere in considerazione alcune osservazioni.
Di default, Spring Security utilizza un sistema di generazione cookie che vengono scambiati ad ogni richiesta client-server, e registra un utente autenticato tra le sessioni attive di Spring in un oggetto chiamato Principal. Questo, insieme alla filter chain (catena dei filtri di sicurezza) di default, permette a Spring di verificare l’autenticazione dell’utente controllando le informazioni tra le sessioni attive.
Per la nostra implementazione con JWT vogliamo:
- escludere i cookie
- creare noi le sessioni
- modificare il comportamento della filter chain per validare il token
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig {
@Bean
public AuthTokenFilter authenticationJwtTokenFilter() {
return new AuthTokenFilter();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests().antMatchers("/api/auth/**").permitAll()
.antMatchers("/api/test/**").permitAll()
.antMatchers("/api/libro/**").permitAll()
.anyRequest().authenticated();
http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().antMatchers("/images/**", "/js/**", "/webjars/**");
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration)
throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
}
La classe WebSecurityConfig qui sopra rappresenta la nostra configurazione per Spring Security.
Cerchiamo di capire meglio quanto scritto. SecurityConfig viene annotata con @Configuration affinché venga trattata come tale da Spring.
Occorre definire il Bean filterChain, nell’esempio:
http.cors().and().csrf().disable();
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
rispettivamente per disabilitare CORS e CSRF e utilizzare una politica stateless delle sessioni.
.addFilterBefore(new .addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
per aggiungere una nuova istanza del filtro AuthorizationFilter creato all’interno della filter chain (catena dei filtri) di Spring Security, subito prima del filtro UsernamePasswordAuthenticationFilter.
.antMatchers("/api/auth/**").permitAll()
.antMatchers("/api/test/**").permitAll()
.antMatchers("/api/libro/**").permitAll()
Riguarda le API che vogliamo esporre pubblicamente, e che quindi non devono essere autenticate, come il login.
Cosi facendo, abbiamo configurato correttamente Spring Security per gestire un’autenticazione con JWT.
N.B. Dalla versione 5.7.0-M2 di Spring Security, che trovi in Spring Boot 2.7.x, la classe WebSecurityConfigurerAdapter che implementa http security è stata deprecata.
public class Credenziali {
@NotBlank
private String username;
@NotBlank
private String password;
// getter e setter
}
public class Registrazione {
@NotBlank
@Size(min = 3, max = 20)
private String username;
@NotBlank
@Size(max = 50)
@Email
private String email;
private Set ruoli;
@NotBlank
@Size(min = 6, max = 40)
private String password;
//getter e setter
}
In questa classe troviamo l'implementazione dei metodi che corrispondono agli endpoint per la gestione dell’autenticazione.
@CrossOrigin(origins = "*", maxAge = 3600)
@RestController
@RequestMapping("/api/auth")
public class AuthController{
@Autowired
private UtenteRepository userRepo;
@Autowired private JwtUtils jwtUtil;
@Autowired private AuthenticationManager authManager;
@Autowired private PasswordEncoder passwordEncoder;
@Autowired private RuoloRepository roleRepository;
@PostMapping("/login")
public ResponseEntity authenticateUser(@Valid @RequestBody Credenziali loginRequest) {
Authentication authentication = authManager.authenticate(
new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = jwtUtil.generateJwtToken(authentication);
DettagliUtente userDetails = (DettagliUtente) authentication.getPrincipal();
List roles = userDetails.getAuthorities().stream()
.map(item -> item.getAuthority())
.collect(Collectors.toList());
return ResponseEntity.ok(new JwtResponse(jwt,
userDetails.getId(),
userDetails.getUsername(),
userDetails.getEmail(),
roles));
}
@PostMapping("/registrazione")
@PreAuthorize("hasAuthority('ADMIN')")
public ResponseEntity registerUser(@Valid @RequestBody Registrazione signUpRequest) {
Optional username = userRepo.cercaPerUsername(signUpRequest.getUsername());
if (username.isPresent()) {
return ResponseEntity
.badRequest()
.body(new MessageResponse("Error: Username già presente!"));
}
Optional email = userRepo.cercaPerEmail(signUpRequest.getEmail());
if (email.isPresent()) {
return ResponseEntity
.badRequest()
.body(new MessageResponse("Error: Email già presente!"));
}
Utente user = new Utente(signUpRequest.getUsername(),
signUpRequest.getEmail(),
passwordEncoder.encode(signUpRequest.getPassword()));
Set strRoles = signUpRequest.getRuoli();
Set roles = new HashSet<>();
if (strRoles == null) {
Ruolo userRole = roleRepository.cercaPerNome(RuoloEnum.UTENTE)
.orElseThrow(() -> new RuntimeException("Error: Role "+RuoloEnum.UTENTE+" is not found"));
roles.add(userRole);
} else {
strRoles.forEach(role -> {
switch (role) {
case "ADMIN":
Ruolo adminRole = roleRepository.cercaPerNome(RuoloEnum.ADMIN)
.orElseThrow(() -> new RuntimeException("Error: Role "+RuoloEnum.ADMIN+" is not found"));
roles.add(adminRole);
break;
case "MODERATORE":
Ruolo modRole = roleRepository.cercaPerNome(RuoloEnum.MODERATORE)
.orElseThrow(() -> new RuntimeException("Error: Role "+RuoloEnum.MODERATORE+" is not found"));
roles.add(modRole);
break;
default:
Ruolo userRole = roleRepository.cercaPerNome(RuoloEnum.UTENTE)
.orElseThrow(() -> new RuntimeException("Error: Role "+RuoloEnum.UTENTE+" is not found"));
roles.add(userRole);
}
});
}
user.setRoles(roles);
userRepo.save(user);
return ResponseEntity.ok(new MessageResponse("Utente registrato correttamente!"));
}
}
/api/auth si aspetta di avere un oggetto con username e password di tipo Credenziali come body della richiesta e se l’utente è presente sul DB risponderà con un oggetto con i dati dell’utente, jwt token e ruoli di appartenenza. Il token JWT va passato, ad ogni richiesta protetta all’interno dell’header, come Bearer token.
In alcune richieste troviamo l’annotazione @PreAuthorize("hasAuthority('ADMIN')") che serve ad abilitare i metodi solo per quei ruoli indicati.
Arrivato a questo punto, avrai configurato correttamente Spring Security per la gestione di un’autenticazione con JWT creando un’API per il login dell’utente, contribuendo cosi alla sicurezza delle API della tua applicazione Spring Boot.
Vedi anche: Proteggere le API REST di SpringBoot 3 con autenticazione JWT e Ruoli
Il codice completo del tutorial lo trovi su github.