Aller au contenu

JWT côté SpringBoot

spring-boot-jwt-workflow

Schéma du login (SignUp et SignIn)

sequence-signin

Classes & Interfaces de Spring

liste des classes de Spring

(Illustrations récupérées sur le site bezkoder.com)

Classes & Interfaces que l’on trouve souvent

Package controller

Package service

Package dto optionnel (DTO = Data Transfer Object)

Package model (nos Entities)

Package repository (pour accèder à la BD)

Package security (spécifique à Spring)

Package exception (il faut gérer les exceptions !)

Contenu du fichier Application.properties

security.jwt.token.secret-key=03888dd6ceb88c3fee89570802fb93d483fd52d70349d8f7e7581ae346cf658
// security.jwt.token.secret=laposte-promo5
# + les infos pour hibernate ou autres pour se connecter à la base de données

Détail de l’appli back-end qui sera présentée

Pensez à ajouter les dépendances pour JWT et la sécurité avec Spring Boot :

	compile group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.0'
    implementation 'org.springframework.boot:spring-boot-starter-security'
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
	<groupId>io.jsonwebtoken</groupId>
	<artifactId>jjwt</artifactId>
	<version>0.9.1</version>
</dependency>

Schéma de la BD

structure bd

Tables et contenus

structure bd

La structure d’API REST construite avec Spring boot est sécurisée avec Spring Security et JWT.

L’API permet de :

Quelques roles ont été définis dans l’API :

Les accès aux fonctionnalités sont définis de la manière suivante :

Les accès aux pages sont définis de la manière suivante :

EndPoints supplémentaires

En plus des EndPoints relatifs au CRUD pour les Films et les Catégories, nous avons besoin d’enregistrer un nouvel utilisateur, nous connecter et récupérer un jeton (JWT) pour une authentification réussie.

base

Quelques explications sur le framework Spring Security

Spring Security est un framework de sécurité léger qui fournit une authentification et un support d’autorisation. Il est livré avec des implémentations d’algorithmes.

Il permet de sécuriser l’interaction entre une application et des utilisateurs

Comment fonctionne Spring Security ?

Il y a un Servlet Dispatcher autrement dit un répartiteur de servlet qui attrape les requêtes et les distribue aux différents controlleurs. Ce sont les fameux Servlets Filters ou Filter Chain, une chaîne de filtre qui effectue les tâches suivantes :

Que fait Spring Security avant d’atteindre votre controleur ?

base

Pour information, voici les filtres de la filter chain de Spring Security :

Ne vous inquiétez pas ! Spring fait le job, pas besoin de connaître toutes ces filtres !

Comment fonctionne l’authentification ?

Il existe 3 scénarios :

Interface UserDetailsService

Cette interface que nous implémentons en créant la classe UserDetailsServiceImpl nous permet d’implémenter la méthode loadUserByUsername() :

  @Override
    public UserDetails loadUserByUsername(String username) {

	}

Pourquoi avons-nous ajouter findByUsername(username) dans notre UserRepository ?

Tout simplement parce-que la méthode loadUserByUsername(String username) en a besoin pour récupérer un uttilisateur à partir de son identifiant.

Si vous jetez un oeil dans le corps de la méthode ci-dessus, vous allez découvrir ce code :

User.withUsername(username)
    .password(user.get().getPassword())
    .authorities(user.get().getRoleList())

le paramètre authorities() permet d’ajouter une collection de rôles qui héritent de GrantedAuthoritiy à notre objet user de type UserDetails. Il en existe d’autres comme LdapAuthority, SwitchUserGrantedAuthority ou OAuth2UserAuthority. Bref, tout dépend des besoins.

Remarque : les rôles ne peuvent pas être nuls.

Finalement, la récupération des chaînes de caractères depuis la base de données et leur identification auprès de Spring Security en tant qu’autorisations est généralement effectuée dans le userDetailsService :

  1. Récupération d’un User en interrogeant la BD.
  2. Récupération de l’attribut autorisations du User qui est mappé en liste de SimpleGrantedAuthorities.

Authorities et Roles

Une Authority n’est qu’une simple chaîne de caractères qui désigne une responsabilité comme READER, ADMIN, BIGBOSS,… On met ce que l’on veut !

Un Role est une Authority précédé du mot clef ROLE_.

En fait, ça veut dire la même chose !! On va dire que le Role est une convention.

Configuration de notre classe WebSecurityConfig (authentification et autorisations)

A quoi sert-elle ?

Indiquer à Spring Security quelles sont les URLs à protéger et avec quelles restrictions. Certaines URLs pourront êtres accessibles à tout le monde (connecté ou pas). Certaines fonctionnalités ne seront visibles que pour les utilisateurs connectés. Certaines Fonctionnalités (ou pages) ne seront de surcroît accessibles qu’aux utilisateurs disposant d’autorisations spécifiques.

Comment cela fonctionne ?

Dans la notre configure(HttpSecurity), le choix des protections derrière lesquelles sécuriser nos URLs s’effectue par le biais du DSL (Domain Specific Language) de Spring Security qui s’avère aisé à mettre en oeuvre et transparent.

Voici les principaux éléments pour configurer vos urls :

antMatchers("/admin/**")
	.access("hasRole('ADMIN') and hasIpAddress('192.168.1.10/20')")

Ci-dessus, Spring Security vérifie que le user est authentifié avec le rôle ADMIN et que la requête vient d’une adresse IP comprise entre 192.168.1.10 et 192.168.1.20.

Défense en profondeur

Pour renforcer la sécurité au-delà des pages web et urls, il existe un moyen de contrôler l’accès aux méthodes que l’on peut trouver :

Cette approche est mise en place par le biais d’annotations affectant les méthodes publiques des beans. Pour rendre possible cette sécurité supplémentaire, il est nécessaire de l’autoriser explicitement dans la classe annotée @Configuration que vous avez découvert dans Cinema-back-api en ajoutant l’annotation @EnableGlobalMethodSecurity

@EnableGlobalMethodSecurity(prePostEnabled = true)

Explications :

CSRF

Certaines requêtes modifient l’état de l’application.

Remarque : Dans le cadre d’une application reposant sur une API Rest et un client Angular ou JS, le paramétrage csrf est plus délicat : le jeton garantissant l’authenticité de la requête est fournit par l’API et doit être conservé (et envoyé avec chaque requête) dans le header vers le client Angular ou JS.

Pour le TP qui va suivre, il faut penser à ajouter les dépendances suivantes dans votre build.gradle pour faciliter le développement en intégrant les packages de sécurité SpringBoot et d’autres bien pratiques.

dependencies {
	implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'mysql:mysql-connector-java'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}

TP de sécurisation sur Client-back-api-jwt

Projet Client Back-api-JWT à récupérer

  1. Créer une Database jpa-client dans votre SGBD MySQL ou autre
  2. Ajouter les dépendances dans le fichier build.gradle pour la partie sécurité
implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

  1. Ajouter un User (comme le AppUser dans l’appli Cinéma), vous pouvez le personnaliser.

  2. Ajouter un contrôleur avec les méthodes sign-in et sing-up pour se connecter et s’enregistrer.

  3. Créer les packages manquant et ajouter les classes indispensables.

Lien vers GitHub de l’application SpringBoot cinema-back-api-p5

cinema-back-api-p5.zip exemple

Correction TP Démo l’application SpringBoot Client-back-api-jwt

Cette correction ne concerne que la partie Sécurité de l’applicaltion.

1. Création de l’entité AppUser


import java.util.List;

import javax.persistence.Column;
import javax.persistence.ElementCollection;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

import com.sun.istack.NotNull;

/**
 * AppUser entity
 */
@Entity
@Table(name="user")
public class AppUser {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@NotNull
	@Column(nullable = false)
	private String username;

	@NotNull
	@Column(nullable = false)
	private String password;

	@ElementCollection(fetch = FetchType.EAGER)
	@Enumerated(EnumType.STRING)
	private List<Role> roleList;
	
	// pour un besoin ultérieur d'oubli ou de modification de mot de passe (optionnel)
	@Column(name = "resetPasswordToken")
	private String resetPasswordToken;
	
	public AppUser() {}

	public AppUser(@NotNull String username, @NotNull String password) {
		this.username = username;
		this.password = password;
	}

	public AppUser(@NotNull String username, @NotNull String password, List<Role> roleList) {
		this.username = username;
		this.password = password;
		this.roleList = roleList;
	}
	
	public Long getId() {
		return id;
	}

	public String getUsername() {
		return username;
	}

	public String getPassword() {
		return password;
	}

	public List<Role> getRoleList() {
		return roleList;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public void setUsername(String username) {
		this.username = username;
	}

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

	public void setRoleList(List<Role> roleList) {
		this.roleList = roleList;
	}

	public String getResetPasswordToken() {
		return resetPasswordToken;
	}

	public void setResetPasswordToken(String resetPasswordToken) {
		this.resetPasswordToken = resetPasswordToken;
	}
}

2. Création d’un Enum pour les rôles

Cela va créer une table pour enregistrer chaque rôle associé à un AppUser. On pourrait le faire différemment en créant une table rôle depuis une Entity. Ici, on simplifie au maximum avec 3 rôles.

Particularité : on implémente l’interface GrantedAuthority. Cela nous oblige a implémenter la méthode getAuthority() pour obtenir les la liste des rôles pour un utilisation donné.

import org.springframework.security.core.GrantedAuthority;

/**
 * Les 3 rôles possibles pour un AppUser.
 */
public enum Role implements GrantedAuthority {

    ROLE_ADMIN, ROLE_CREATOR, ROLE_READER;

    @Override
    public String getAuthority() {
        return name();
    }
}

3. Création d’une classe AppUserDto (non obligatoire)

/**
 * Specifique : AppUser DTO permet de renvoyer un User sans le mot de passe (REST response).
 */
public class AppUserDto {

    private Long id;
    private String username;
    private List<Role> roleList;

    
    public AppUserDto() { }

    public AppUserDto(@NotNull String username) {
        this(username,null);
    }

    public AppUserDto(@NotNull String username, List<Role> roleList) {
        this.username = username;
        this.roleList = roleList;
    }

	public Long getId() {
		return id;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public String getUsername() {
		return username;
	}

	public void setUsername(String username) {
		this.username = username;
	}

	public List<Role> getRoleList() {
		return roleList;
	}

	public void setRoleList(List<Role> roleList) {
		this.roleList = roleList;
	}
	
    
}

4. Création d’une interface AppUserRepository

Pour nous permettre d’effectuer des actions sur notre table User de notre BD.

public interface AppUserRepository extends JpaRepository<AppUser, Long> {

    Optional<AppUser> findByUsername(String username);
    boolean existsByUsername(String username);
    void deleteByUsername(String username);
    Optional<AppUser> findByResetPasswordToken(String token);
}

5. Création d’une couche service avec AppUserService et son implémentation.

On pourrait s’en passer mais c’est préférable d’ajouter une couche service pour la partie maintenance du code. Il suffit d’ajouter l’annotation @Service à notre interface.

@Service
public interface AppUserService {

	
	
	/**
     * Methode qui permet à un utilisateur de se connecter.
     * @param username : nom de l'utilisateur.
     * @param password : mot de passe de l'utilisateur.
     * @returnun JWT si credentials est valide, throws InvalidCredentialsException otherwise.
     * @throws InvalidCredentialsException
     */
    String signin(String username, String password) throws InvalidCredentialsException;

    /**
     * Methode qui permet de s'inscrire.
     * @param user nouvel utilisateur.
     * @return un JWT si user n'existe pas déjà !
     * @throws ExistingUsernameException
     */
    String signup(AppUser user) throws ExistingUsernameException;

    /**
     * Methode qui retourne tous les utilisateurs de la bd
     * @return the list of all application users.
     */
    List<AppUser> findAllUsers();

    /**
     * Methode qui retourne un utilisateur à partir de son username
     * @param username the username to look for.
     * @return an Optional object containing user if found, empty otherwise.
     */
    Optional<AppUser> findUserByUserName(String username);
    
    /**
     * Methode qui retourne un utilisateur à partir de son username
     * @param username nom à effacer dans la base
     * @return une chaîne correspondant à une réponse si ok ou ko
     * @throws NotExistingUsernameException
     */
    String deleteByUserName(String username) throws NotExistingUsernameException;
    
	/**
     * Méthode pour retourner un utilisateur en fonction du token
     * @param token
     * @return un utilisateur
     */
    public Optional<AppUser> findByResetPasswordToken(String token);
    
}

La partie implémentation de l’interface toujours avec l’annotation @Service :

@Service
public class AppUserServiceImpl implements AppUserService {

	@Autowired
	private AppUserRepository appUserRepository; // permet communication avec la BD

	@Autowired
	private BCryptPasswordEncoder passwordEncoder; // permet l'encodage du mot de passe

	@Autowired
	private JwtTokenProvider jwtTokenProvider;	// permet la fourniture du Jeton (Token)

	@Autowired
	private AuthenticationManager authenticationManager; // gestionnaire d'authentification


	/**
	 * Permet de se connecter en encodant le mot de passe avec génération du token.
	 */
	@Override
	public String signin(String username, String password) throws InvalidCredentialsException {
		try {
			authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
			return jwtTokenProvider.createToken(username, appUserRepository.findByUsername(username).get().getRoleList());
		} catch (AuthenticationException e) {
			throw new InvalidCredentialsException();
		}
	}

	@Override
	public String signup(AppUser user) throws ExistingUsernameException {
		if (!appUserRepository.existsByUsername(user.getUsername())) {
			AppUser userToSave = new AppUser(user.getUsername(), passwordEncoder.encode(user.getPassword()), user.getRoleList());
			appUserRepository.save(userToSave);
			return jwtTokenProvider.createToken(user.getUsername(), user.getRoleList());
		} else {
			throw new ExistingUsernameException();
		}
	}

	@Override
	public List<AppUser> findAllUsers() {
		return appUserRepository.findAll();
	}

	@Override
	public Optional<AppUser> findUserByUserName(String username) {
		return appUserRepository.findByUsername(username);
	}

	@Override
	public String deleteByUserName(String username) throws NotExistingUsernameException {

		if (appUserRepository.existsByUsername(username))
		{
			appUserRepository.deleteByUsername(username);
			return "Utilisateur effacé !";
		}
		else {
			throw new NotExistingUsernameException();
		}

	}

	@Override
	public Optional<AppUser> findByResetPasswordToken(String token) {
		
		return null;
	}
	
	/**
	* Optionnelle pour utilisation ultérieure
	*
	**/
	 public Optional<AppUser> getByResetPasswordToken(String token) {
	        return appUserRepository.findByResetPasswordToken(token);
	    }
	     
	    public void updatePassword(AppUser appUser, String newPassword) {
	        appUser.setPassword(passwordEncoder.encode(newPassword));
	        appUser.setResetPasswordToken(null);
	        appUserRepository.save(appUser);
	    }

}

6. Création de la classe UserDetailsServiceImpl qui implémente l’interface UserDetailsService

Cette implémentation nous oblige à implémenter la méthode loadUserByUsername().

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private AppUserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username)  {
        final Optional<AppUser> user = userRepository.findByUsername(username);

        if (!user.isPresent()) {
            throw new UsernameNotFoundException("utilisateur '" + username + "' introuvable");
        }

        return User
                .withUsername(username)
                .password(user.get().getPassword())
                .authorities(user.get().getRoleList())
                .build(); // cette dernière méthode permet la construction d'un objet UserDetails
    }
}

7. Partie relative à la gestion du token (JWT)

7.1 JsonWenToken

Classe avec une seule méthode pour renvoyer une chaîne de caractère qui correspond au token (et rien d’autre comme une classe DTO). Vous pouvez la nommez comme bon vous semble !

public class JsonWebToken {
    private final String token;

    public JsonWebToken(String token) {
        this.token = token;
    }

    public String getToken() {
        return token;
    }
}

7.2 JsonTokenProvider

Cette classe utilitaire est chargée de fournir le Jeton (Token) et les vérifications. On peut lui mettre l’annotation @Component.


@Component
public class JwtTokenProvider {

	// on récupère le secret dans notre fichier application.properties
	// on peut aussi le mettre en dur ici genre "monMotSecret123456789"
	@Value("${security.jwt.token.secret-key:secret-key}")
	private String secretKey;

	// ici on met la valeur par défaut de la durée du jeton
	@Value("${security.jwt.token.expire-length:3600000}")
	private long validityInMilliseconds = 3600000; // 1h pour être pénard

	@Autowired
	private UserDetailsService userDetailsService;

	/**
	 * Cette méthode d'initialisation s'exécute avant le constructeur
	 * Elle encode notre code secret en base64 pour la transmission dans le header.
	 */
	@PostConstruct
	protected void init() {
		secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
	}


	/**
	 * @param username le username.
	 * @param roles les roles.
	 * @return une chaîne de caractères du JWT créé.
	 * @throws JsonProcessingException 
	 */
	public String createToken(String username, List<Role> roles){

		Claims claims = Jwts.claims().setSubject(username);
		claims.put("auth", roles.stream().map(s -> new SimpleGrantedAuthority(s.getAuthority())).filter(Objects::nonNull).collect(Collectors.toList()));

		System.out.println("claims = "+claims);
		// claims = {sub=pbouget, auth=[ROLE_ADMIN, ROLE_CREATOR, ROLE_READER]}
		Date now = new Date();
		Date validity = new Date(now.getTime() + validityInMilliseconds);

		String leToken = Jwts.builder()//
				.setClaims(claims)								// le username avec les roles ou setPayload()
				.setIssuedAt(now)								// 1589817421  pour le 18 mai 2020 à 17 heure 57
				.setExpiration(validity)						// 1589821021 même date avec 1 heure de plus
				.signWith(SignatureAlgorithm.HS256, secretKey) 	// la signature avec la clef secrête.
				.compact();										// concatène l'ensemble pour construire une chaîne
		return leToken;
	}

	/**
	 * Methode qui retourne un objet Authentication basé sur JWT.
	 * @param token : le token pour l'authentification.
	 * @return l'objet Authentication si le Username est trouvé.
	 */
	public Authentication getAuthentication(String token) {
		UserDetails userDetails = userDetailsService.loadUserByUsername(getUsername(token));
		return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
	}

	/**
	 * Methode qui extrait le userName (le sub ou subject) du JWT.
	 * @param token : Token à analyser.
	 * @return le UserName comme chaîne de caractères.
	 */
	public String getUsername(String token) {

		return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
	}

	/**
	 * Méthode qui récupère la requete HTTP et retourne une chaîne.
	 * L'entête doit contenir un champ d'autorisation ou JWT ajoute le token après le mot clef Bearer.
	 * @param requete : la requête à tester.
	 * @return le JWT depuis l'entête HTTP.
	 */
	public String resolveToken(HttpServletRequest requeteHttp) {
		String bearerToken = requeteHttp.getHeader("Authorization");
		if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
			return bearerToken.substring(7);
		}
		return null;
	}

	/**
	 * Methode qui vérifie la validité du token.
	 * La signature doit être correcte et la durée de validité du Token doit être après "now" (maintenant)
	 * @param token : Token à valider
	 * @return True si le Token est valide sinon on lance l'exception InvalidJWTException.
	 * @throws InvalidJWTException
	 */
	public boolean validateToken(String token) throws InvalidJWTException {
		try {
			Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
			return true;
		} catch (JwtException | IllegalArgumentException e) {
			throw new InvalidJWTException();
		}
	}
}

7.3 JwtTokenFilter qui hérite de OncePerRequestFilter

Filtre specifique en charge d’analyser la requête HTTP qui arrive vers notre Serveur et qui doit contenir un JWT valide. Ce filtre ne sera exécuté qu’une seule fois.

public class JwtTokenFilter extends OncePerRequestFilter {
    private JwtTokenProvider jwtTokenProvider;

    public JwtTokenFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        String token = jwtTokenProvider.resolveToken(httpServletRequest);
        try {
            if (token != null && jwtTokenProvider.validateToken(token)) {
                Authentication auth = jwtTokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        } catch (InvalidJWTException ex) {
        	// permet de garantir que le AppUser n'est pas authentifié
            SecurityContextHolder.clearContext();
            httpServletResponse.sendError(HttpStatus.BAD_REQUEST.value(), "JWT invalide !");
            return;
        }

        filterChain.doFilter(httpServletRequest, httpServletResponse);
    }
}

7.4 WebSecurityConfig

Classe qui permet de configurer la sécurité plutôt que de laisser faire Spring (avant Spring Boot 2.7).

/**
 * Configuration de Sécurité globale pour notre REST API.
 * L'annotation @Configuration permet d'être scannée au démarrage de l'application
 */
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

	@Autowired
	private JwtTokenProvider jwtTokenProvider;

	@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
	@Override
	public AuthenticationManager authenticationManagerBean() throws Exception {
		return super.authenticationManagerBean();
	}

	/**
	 * Methode qui configure la sécurité HTTP. Version simplifiée
	 * @param http HttpSecurity object to configure.
	 * @throws Exception
	 */
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.cors();
		http.csrf().disable()
		.sessionManagement()
		.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
		.and()
		.authorizeRequests()
		.antMatchers("/client/**").permitAll() // accessible sans besoin de s'authentifier
		.antMatchers("/api/user/sign-in").permitAll() // se connecter
		.antMatchers("/api/user/sign-up").permitAll() // s'inscrire et accessible sans besoin de s'authentifier
		.antMatchers("api/user/all").hasAuthority("ROLE_ADMIN") // uniquement pour le rôle admin
		.anyRequest().authenticated(); // tout le reste est autorisé par un utilisateur authentifié

		// Appliquer un filtre avec le token pour toutes requêtes HTTP
		http.addFilterBefore(new JwtTokenFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

	}

	/**
	 * Methode qui configure la sécurité web.
	 * Pas besoin d'autorisation pour les fichiers dans resources
	 * @param web : WebSecurity
	 * @throws Exception
	 */
	@Override
	public void configure(WebSecurity websecurity) throws Exception {
		websecurity.ignoring().antMatchers("/resources/**");
	}
}

7.5 WebSecurityConfig (pour Spring 2.7 et ultérieur)

Classe qui permet de configurer la sécurité plutôt que de laisser faire Spring (après Spring Boot 2.7).

/**
 * Configuration de Sécurité globale pour notre REST API.
 * L'annotation @Configuration permet d'être scannée au démarrage de l'application
 * L'annotation @EnableGlobalMethodSecurity(prePostEnabled = true) permet d'autoriser les méthodes à gérer les autorisations
 */
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig {
  @Autowired
  UserDetailsServiceImpl userDetailsService;

  @Autowired
  private AuthEntryPointJwt unauthorizedHandler;

  @Bean
  public AuthTokenFilter authenticationJwtTokenFilter() {
    return new AuthTokenFilter();
  }
 
  @Bean
  public DaoAuthenticationProvider authenticationProvider() {
      DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
       
      authProvider.setUserDetailsService(userDetailsService);
      authProvider.setPasswordEncoder(passwordEncoder());
   
      return authProvider;
  }

  @Bean
  public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
    return authConfig.getAuthenticationManager();
  }

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }
 
  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.cors().and().csrf().disable()
        .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
        .authorizeRequests()
   		.antMatchers("/client/**").permitAll() // accessible sans besoin de s'authentifier
		.antMatchers("/api/user/sign-in").permitAll() // se connecter
		.antMatchers("/api/user/sign-up").permitAll() // s'inscrire et accessible sans besoin de s'authentifier
		.antMatchers("api/user/all").hasAuthority("ROLE_ADMIN") // uniquement pour le rôle admin
		.anyRequest().authenticated(); // tout le reste est autorisé par un utilisateur authentifié
    
    http.authenticationProvider(authenticationProvider());

    http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
    
    return http.build();
  }
}

8. Il vous reste à créer les différentes Classes d’Exceptions

Cela va vous permettre d’afficher des messages compréhensibles.

Voici la liste des noms des classes d’exceptions personnalisées :

Je vous laisse les écrire…

9. Le Rest controlleur AppUserController

pour pouvoir gérer l’inscription, la connection et d’autres actions propre à l’utilisateur…

@RestController
@RequestMapping("/api/user")
public class AppUserController {
	
	@Autowired
    private AppUserService appUserService;

    /**
     * Methode pour enregistrer un nouvel utilisateur dans la BD.
     * @param user utiliateur.
     * @return un JWT si la connection est OK sinon une mauvaise réponse
     */
    @PostMapping("/sign-up")
    public ResponseEntity<JsonWebToken> signUp(@RequestBody AppUser user) {
        try {
            return ResponseEntity.ok(new JsonWebToken(appUserService.signup(user)));
        } catch (ExistingUsernameException ex) {
            return ResponseEntity.badRequest().build();
        }
    }

    /**
     * Methode pour se connecter (le user existe déjà).
     * @param user : utilisateur qui doit se connecter.
     * @return un JWT si la connection est OK sinon une mauvaise réponse.
     */
    @PostMapping("/sign-in")
    public ResponseEntity<JsonWebToken> signIn(@RequestBody AppUser user) {
        try {
        	// ici on créé un JWT en passant l'identifiant et le mot de passe
        	// récupéré de l'objet user passé en paramètre.
            return ResponseEntity.ok(new JsonWebToken(appUserService.signin(user.getUsername(), user.getPassword())));
        } catch (InvalidCredentialsException ex) {
        	// on renvoie une réponse négative
            return ResponseEntity.badRequest().build();
        }
    }

    /**
     * Methode pour retourner tous les utilisateurs de la BD.
     * Cette méthode est accesible pour les utilisateurs ayant le rôle ROLE_ADMIN.
     * @return liste de tous les utilisateurs enregistrés en BD.
     */
   @GetMapping("/all")
   @PreAuthorize("hasRole('ROLE_ADMIN')")
    public List<AppUser> getAllUsers() {
        return appUserService.findAllUsers();
     
 }
   
   /**
    * Permet de retourner les infos sans les mots de passe en passant par DTO
    * C'est mieux et plus sécurisant !
    * @return
    */
   	@GetMapping("/admin/all")
   	@PreAuthorize("hasRole('ROLE_ADMIN')")
    public List<AppUserDto> getAllAdminUsers() {
           return appUserService.findAllUsers().stream().map(appUser -> new AppUserDto(appUser.getUsername(), appUser.getRoleList())).collect(Collectors.toList());
        
    }
   	
   	@DeleteMapping("/delete/{appUserName}")
 	@PreAuthorize("hasRole('ROLE_ADMIN')")
   	public ResponseEntity<?> deleteUser(@PathVariable String appUserName) {
   		try {
            return ResponseEntity.ok(appUserService.deleteByUserName(appUserName));
        } catch (NotExistingUsernameException ex) {
            return ResponseEntity.ok(ex.getMessage());
        }
   		
   	}
    


    /**
     * Methode pour récupérer le user dans la bd à partir de son username.
     * Cette méthode est uniquement accessible par un user Admin.
     * @param appUserName à chercher.
     * @return un User est trouvé sinon réponse non trouvée.
     */
    @GetMapping("/{appUserName}")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    public ResponseEntity<AppUserDto> getOneUser(@PathVariable String appUserName) {
        Optional<AppUser> appUser = appUserService.findUserByUserName(appUserName);
        if (appUser.isPresent()) {
            return ResponseEntity.ok(new AppUserDto(appUser.get().getUsername(), appUser.get().getRoleList()));
        } else {
            return ResponseEntity.notFound().build();
        }
    }
   
}

10. Bean BCryptPasswordEncoder

ajouter un bean pour l’encodage. Vous pouvez le mettre dans votre classe de lancement de SpringBoot ou ailleurs

	/**
	 * Ceci est un Bean, un composant
	 * Méthode de Hachage
	 * Bcrypt est un algorithme de hachage considéré comme le plus sûr.
	 * Bcrypt est un algorithme de hashage unidirectionnel,
	 * vous ne pourrez jamais retrouver le mot de passe sans connaitre à la fois le grain de sel,
	 * la clé et les différentes passes que l'algorithme à utiliser.
	 * Voir le <a href="https://bcrypt-generator.com/"> site pour effectuer un test</a>
	 * @return
	 */
	@Bean
	public BCryptPasswordEncoder bCryptPasswordEncoder() {
		return new BCryptPasswordEncoder();
	}

11. Secret

Il vous reste à ajouter 2 lignes dans votre fichier applications.properties.

security.jwt.token.secret-key=LaPoste-The-Best-Promo5
spring.main.allow-circular-references=true 

12. The End

Vous pouvez utiliser ces classes pour implémenter la partie sécurité de n’importe quelle application spring boot en ajoutant vos classes contrôleurs, service, repository, model et autres spécifiques à votre application. Vous pouvez aussi modifier les codes des différentes classes selon vos besoins. Tout réécrire n’est pas forcément indispensable, par contre, bien prendre le temps de lire le code, regarder la documentation de Spring Security.

https://docs.spring.io/spring-security/site/docs/5.4.6/reference/html5/

Quelques diverses ressources sur le web