< Home
Stampa

Sicurezza e login

Sommario

La sessione utente

Come visto nella lezione sui servizi REST, REST non ha un meccanismo di sicurezza integrato, ed è, come HTTP, totalmente stateless. In altri termini non è possibile creare direttamente una sessione utente che si ricorda ad ogni chiamata qual è l’utente che sta facendo la richiesta.

Ma a cosa servono le sessioni utente? Per personalizzare le risposte ai client. Quindi utenti diversi, di fronte alla chiamata allo stesso servizio, vedranno risultati personalizzati. Ad esempio un ecommerce mostrerà il cartello dell’utente collegato, un servizio di streaming i film già visti, ecc.

Per poter gestire quindi delle sessioni utente si deve implementare un meccanismo che garantisca l’identificazione del client. Questo è ottenibile tramite questo workflow:

  1. Il client esegue una autenticazione (login/password) (inseriti dall’utente)
  2. Il server genera un token di sessione univoco per l’utente
  3. Il client lo riceve e lo utilizza in tutte le chiamate (di norma nell’header della richiesta).
  4. il server verifica la correttezza del token, e se corretto, riconosce l’utente e gestisce la richiesta come proveniente da quell’utente.

Il sistema di token più diffuso è il JSON Web Token (JWT), un sistema di autenticazione che il server non ha bisogno di salvare nel proprio database, perché trasporta internamente tutte le informazioni per identificare ed autorizzare l’utente. JWT è spiegato sempre nella lezione sopra indicata.

Il token viene criptato dal server con una sua chiave privata segreta lato server ed inviato al client dopo il login. Il client lo deve conservare per tutta la durata di sessione e inserirlo nell’header di ogni richiesta tramite questa coppia nome valore:

"Authorization: Bearer xxxxx.yyyyy.zzzzz"

Il server decripta il token, identifica l’utente e verifica ruolo e permessi, concedendo accesso alla risorsa se autorizzato.

La forza di JWT sta nel fatto che il server non deve memorizzare a sua volta il token nel proprio database, è sufficiente decrittarlo con la chiave “master” presente solo sul server.

Una semplice applicazione per gestire le sessioni

Capito questo meccanismo scriviamo una semplice applicazione SpringBoot che permette di svolgere queste operazioni:

  1. Registrare un nuovo utente su Database
  2. Fare login con un utente registrato
  3. Accedere ad un risorsa accessibile e personalizzata per utente loggato

Prima di tutto ci serve un utente admin. In questa applicazione infatti abbiamo bisogno di un utente amministratore, l’unico che ha il permesso di creare utenti.

Creiamo prima di tutto l’entità User:

package it.cipiaceinfo.login;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "users")
public class User {
    
    @Id @GeneratedValue
    private Long id;

    @Column(unique=true, nullable=false)
    private String username;

    @Column(nullable=false)
    private String password;

    public User() {}

    public User(String username, String password) {
      this.username = username;
      this.password = password;
    }

    public User(LoginDTO loginDTO) {
        this.username = loginDTO.username();
        this.password = loginDTO.password();
    }

    public String getUsername() {
        return this.username;
    }

    public String getPassword() {
        return this.password;
    }

    public void setPassword(String password) {
      this.password = password;
    }

}

Il repository prevede un metodo importante, la ricerca per username:

package it.cipiaceinfo.login;

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long>{
    Optional<User> findByUsername(String username);
}

Abbiamo poi bisogno di 3 DTO:

  • RegisterDTO: serve per restituire il nome dell’utente appena registrato
  • LoginDTO: serve per il client per inviare le credenziali di login
  • TokenDTO: serve per inviare al client il token se il login ha successo
package it.cipiaceinfo.login;

public record RegisterDTO(String username) {
   
}

package it.cipiaceinfo.login;

public record LoginDTO(String username, String password) {
   
}

package it.cipiaceinfo.login;

public record TokenDTO(String token) {
    
}

(si ricorda che vanno in file differenti)

Veniamo ora all’UserService. Questa classe ha 3 metodi:

  • createAdmin(): viene eseguito all’avvio per creare l’admin, se non esiste
  • register(LoginDTO): registra un utente nuovo
  • login(LoginDTO): verifica le credenziali
package it.cipiaceinfo.login;

import java.util.Optional;

import org.springframework.stereotype.Service;

@Service
public class UserService {
    private final UserRepository userRepository;
    private final String ADMIN = "admin";
    private final String PASSWORD = "password";

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;    
        this.createAdmin();    
    }

    public void createAdmin() {
        Optional<User> optionalUser = userRepository.findByUsername(ADMIN);
        if (optionalUser.isPresent()) {
            return;
        } else {
            User user = new User(ADMIN, PASSWORD);
            userRepository.save(user);            
        }
    }

    public void register(LoginDTO loginDTO) {
        Optional<User> optionalUser = userRepository.findByUsername(loginDTO.username());
        if (optionalUser.isPresent()) {
            User user = optionalUser.get();
            user.setPassword(loginDTO.password());
            userRepository.save(user);            
        } else {
            User user = new User(loginDTO);
            userRepository.save(user);            
        }
    }

    public boolean login(LoginDTO loginDTO) {
        Optional<User> optionalUser = userRepository.findByUsername(loginDTO.username());
        if (optionalUser.isPresent()) {
            User user = optionalUser.get();
            if (loginDTO.password().equals(user.getPassword())) {
                return true;
            } else {
                return false;                
            }
        } else {
            return false;
        }
    }

    public String getADMIN() {
      return ADMIN;
    }

}

Come si può vedere non è il service che si occupa di verificare se register viene eseguita da un utente admin. E’ il controller che se ne occupa.

Il controller espone questi serviz

  • POST /api/auth/register: registra un nuovo utente
  • POST /api/auth/login: effettua il login
  • GET /protected/data: a questa richiesta si accede da solo loggati

Vediamo il codice:

package it.cipiaceinfo.login;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api")
@CrossOrigin(origins = "*")
public class UserController {

   private final UserService userService;
   private final JwtUtil jwtUtil;

   public UserController(JwtUtil jwtUtil, UserService userService) {
      this.jwtUtil = jwtUtil;
      this.userService = userService;
   }

   @PostMapping("/auth/register")
   public ResponseEntity<RegisterDTO> register(
         @RequestHeader(value = "Authorization", required = false) String authHeader,
         @RequestBody LoginDTO credentials) {
      
      if (authHeader == null || !authHeader.startsWith("Bearer ")) {
         return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
      }

      try {
         String token = authHeader.replace("Bearer ", "");
         String user = jwtUtil.validateTokenAndGetUsername(token);


         if (userService.getADMIN().equals(user)) {
            userService.register(credentials);
            return ResponseEntity.status(HttpStatus.OK).body(new RegisterDTO(credentials.username()));
         } else {
            
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
         }
      } catch (Exception e) {
         // Token scaduto o manomesso
         return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
      }
   }

   @PostMapping("/auth/login")
   public ResponseEntity<TokenDTO> login(@RequestBody LoginDTO credentials) {
      if (userService.login(credentials)) {
         String token = jwtUtil.generateToken(credentials.username());
         return ResponseEntity.status(HttpStatus.OK).body(new TokenDTO(token));
      } else {
         return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
      }
   }

   @GetMapping("/protected/data")
   public ResponseEntity<String> getSecretData(
         @RequestHeader(value = "Authorization", required = false) String authHeader) {
      
      if (authHeader == null || !authHeader.startsWith("Bearer ")) {
         return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Token mancante");
      }

      try {
         String token = authHeader.replace("Bearer ", "");
         String user = jwtUtil.validateTokenAndGetUsername(token);
         return ResponseEntity.ok("Ciao " + user + ", questi sono dati protetti dal server!");
      } catch (Exception e) {
         return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Token non valido o scaduto");
      }
   }
}

Il controller per funzionare utilizza una classe JwtUtil, che espone due metodi:

  • generateToken(username): genera un nuovo token al login
  • validateTokenAndGetUsername(token): valida il token e restituisce l’username

Prima di vederla in dettaglio vediamo i singoli metodi:

  • register: questo metodo dopo aver controllato l’esistenza del token JWT, lo valida col metodo validateTokenAndGetUsername. Se l’utente loggato è admin, allora crea il nuovo utente.
  • login: questo metodo controlla le credenziali e crea in caso positivo il token con generateToken
  • getSecretData: questo metodo dopo aver controllato l’esistenza del token JWT, lo valida col metodo validateTokenAndGetUsername. In questo caso genera una stringa di risposta personalizzata.

Naturalmente in un progetto reale l’username sarà utilizzato per identificarlo nel database e offrire contenuti personalizzati e/o protetti.

Come detto sopra vediamo la classe JwtUtil:

package it.cipiaceinfo.login;

import org.springframework.stereotype.Component;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.util.Date;

@Component
public class JwtUtil {
    private static final Key SECRET_KEY = Keys.secretKeyFor(SignatureAlgorithm.HS256);
    private static final int EXPIRATION_TIME = 3600000;

    public String generateToken(String username) {
        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
                .signWith(SECRET_KEY)
                .compact();
    }

    public String validateTokenAndGetUsername(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(SECRET_KEY)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }
}

Questa classe genera una chiave “master” e poi usa la libreria di JWT (inclusa in Springboot) per generare il token e validarlo. Quello che ci interessa è capire il meccanismo:

  • la chiave master garantisce che il token JWT che si crea ad inizio sessione sia autentico
  • il token quando viene decrittato ne viene garantita l’autenticità

E’ necessario infine, per far funzionare il tutto, scrivere una classe di configurazione, che attiva il controllo del token per l’autenticazione.

package it.cipiaceinfo.login;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
            .csrf(csrf -> csrf.disable())
            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth                
                .anyRequest()
                .permitAll());            
        return httpSecurity.build();
    }
}

In pratica questa SecurityConfig abilita un meccanismo che abilita i controller a verificare l’header delle chiamate e disabilita la sicurezza di default di SpringBoot.

Test dell’applicazione

Qui le chiamate curl per testare l’applicazione:

Login come admin:

curl -X POST http://localhost:8080/api/auth/login \
     -H "Content-Type: application/json" \
     -d '{
           "username": "admin",
           "password": "password"
         }'

Una volta ottenuto il token JWT di admin eseguire la registrazione di un utente:

curl -X POST http://localhost:8080/api/auth/register \
     -H "Authorization: Bearer ADMIN_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{
           "username": "mario",
           "password": "1234"
         }'

Una volta registrato l’utente possiamo fare login con questo:

curl -X POST http://localhost:8080/api/auth/login \
     -H "Content-Type: application/json" \
     -d '{
           "username": "mario",
           "password": "mario-password"
         }'

Il sistema restituisce il token JWT dell’utente.

A questo punto possiamo richiedere la risorsa accessibile solo da loggati:

curl -X GET http://localhost:8080/api/protected/data \
     -H "Authorization: Bearer USER_TOKEN"

Il sistema risponderà:

Ciao mario, questi sono dati protetti dal server!