Introduction

Le framework Spring permet de créer des applications d'entreprises. Dans ce cours nous allons voir comment l'exploiter à travers deux extensions majeures Spring Boot et Spring Security. Premièrement, Spring Boot facilite la création d'applications autonomes prêtes à l'emploi avec un minimum de configuration. Puis Spring Security qui fournit l'authentification, l'autorisation et d'autres fonctionnalités de sécurité pour les applications d'entreprise.

Plan du cours

  1. Présentation de l'architecture mise en place
  2. Création du service compte bancaire avec Spring Boot
  3. Création du service d'authentification avec Spring Security
    1. Explication générale
    2. Authentification Basic
    3. Authentification JWT

Disclaimer

Ce tutoriel se concentre sur la mise en œuvre et la réalisation efficace d'une application Spring avec Spring Security. Nous ne fournirons pas de détails approfondis sur le fonctionnement interne de Spring. Pour toute compréhension approfondie de Spring, nous vous encourageons à effectuer des recherches personnelles.

Architecture Générale

Nous optons pour une architectecure en microservices où chaque service peut être deployé séparément et dispose de sa propre base de données.

Chaque service reprendra le même fonctionnement :

  • Un service représentant le comtpe bancaire d'un client.
  • Un service permettant l'authentification d'un client
  • Un dernier service gérant la communication vers les deux autres microservices.

Configuration en MicroService

Nous utiliserons Maven pour gérer nos services et les librairies externes utilisées.

Découpage du projet

Comme evoqué quand le chapitre précédent nous allons déployer 3 microservices. Cela signifie donc 3 projets Maven indépendants. Cependant afin de faciliter l'utilisation des librairies nous allons regrouper ces 3 projets sous un projet parent prixbanque

POM parent

Propriétés

<properties>
    <microservice.version>0.0.1-SNAPSHOT</microservice.version>
    <java.version>17</java.version>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <spring.boot.dependencies.version>2.7.4</spring.boot.dependencies.version>
</properties>

Microservice

Le pom parent déclare également les 3 microservices que nous allons développer ultérieurement.

<modules>
    <module>bankaccount</module>
    <module>registration</module>
    <module>gateway</module>
</modules>

On doit également ajouter ces modules comme dépendance du projet. Ainsi dans le bloc <dependencyManagement></dependencyManagement> nous rajoutons

<!-- MicroServices-->
<dependency>
    <groupId>adriencaubel.fr</groupId>
    <artifactId>registration</artifactId>
    <version>${microservice.version}</version>
</dependency>
<dependency>
    <groupId>adriencaubel.fr</groupId>
    <artifactId>connection</artifactId>
    <version>${microservice.version}</version>
</dependency>
<dependency>
    <groupId>adriencaubel.fr</groupId>
    <artifactId>banktransfert</artifactId>
    <version>${microservice.version}</version>
</dependency>

Les autres dépendances

<!-- Microservice -->
...

<!-- Spring -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    <version>${spring.boot.dependencies.version}</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>${spring.boot.dependencies.version}</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <version>${spring.boot.dependencies.version}</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
    <version>${spring.boot.dependencies.version}</version>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
    <version>3.1.4</version>
</dependency>


<!-- Base de données -->
<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
    <version>2.3.1</version>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.5.0</version>
    <scope>runtime</scope>
</dependency>

<!-- Utility -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.24</version>
    <scope>provided</scope>
</dependency>

<!-- Tests -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <version>5.9.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <version>5.9.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.platform</groupId>
    <artifactId>junit-platform-runner</artifactId>
    <version>1.9.1</version>
    <scope>test</scope>
</dependency>

Le build

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <version>${spring.boot.dependencies.version}</version>
        </plugin>
    </plugins>
</build>

Architecture d'un microservice

Nous avons évoqué l'architecture générale de notre application. Maintenant nous nous intéressons à l'architecture au sein d'un service. Nous une architecture par couche (layer) appelée Clean Architecture.

La Clean Architecture met l'accent sur la séparation des préoccupations et sur la dépendance des couches internes du système sur les couches externes. Elle favorise également l'indépendance des frameworks, des bibliothèques et des outils externes.

Elle rend les systèmes logiciels maintenables, évolutifs et testables en isolant les détails d'implémentation dans les couches internes et en favorisant l'indépendance des couches externes.

https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

Brève introduction

Entités

L'entité ne dépend de rien, sauf éventuellement d'autres entités. Elle contient des données (état) et une logique réutilisables pour diverses applications. Elles sont le cœur de notre application.

Cas d'utililisation

Les cas d'utilisation interagissent avec les entités (dépendent d'elles) et détiennent la logique de l'application spécifique. Les UseCase peuvent être lié à des librairies. Par exemple avec une couche d'accès aux données

Dans notre application, le diagramme BPMN nous permet d'identifier facilement quels seront ces cas d'utilisation

Adaptateurs

La couche d'adaptateur est une couche qui sert d'interface entre la logique métier (domaine) et les frameworks ou technologies externes tels que les bases de données, les services web, les systèmes de messagerie, accès aux bases de données, etc. Elle a pour rôle de traduire les données provenant de ces sources externes vers un format que la logique métier peut comprendre et utiliser, et vice versa.

https://refactoring.guru/fr/design-patterns/adapter

Librairies

Le cœur de l'application doit être indépendant des points d'entrée et des points de sortie (les librairies). En effet, si un framework ou une technologie externe ne répond plus aux besoins du système, il peut être remplacé facilement sans impacter la logique métier.

Exemple

Chaque service suivra la même architecture que ci-dessous. Nous reviendrons en détail sur leur implémentation dans les chapitres suivants.

Introduction

Ce module est dédié à la gestion d'un compte bancaire. Nous distinguons la notion de compte bancaire et d'utilisateur, un utilisateurs peut posséder plusieurs compte bancaire.

Analyse du besoin

Dans notre application un compte bancaire se traduit par les actions suivantes :

  • Créer le compte bancaire pour un utilisateur
  • Mettre à jour le compte
  • Supprimer le compte bancaire
  • Consulter son compte bancaire
  • Afficher tous ses comptes bancaires

Nous rajoutons également quelques règles concernant les comptes bancaires :

  • Un compte bancaire peut être du type CHEQUE ou EPARGNE
  • Un compte bancaire porte un nom (e.g. compte principale)
  • Un compte bancaire à une Balance

Configuration

Nous avons besoin des dépendances Spring suivantes :

  • Spring Boot
  • Spring Web
  • Spring Data JPA

Pom

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>uqac.groupe6</groupId>
		<artifactId>prixbanque</artifactId>
		<version>0.0.1-SNAPSHOT</version>
	</parent>
	<artifactId>bankaccount</artifactId>


	<dependencies>
		<!-- Spring -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>


		<!-- Base de données -->
		<dependency>
			<groupId>javax.xml.bind</groupId>
			<artifactId>jaxb-api</artifactId>
		</dependency>
		<dependency>
			<groupId>org.postgresql</groupId>
			<artifactId>postgresql</artifactId>
			<scope>runtime</scope>
		</dependency>

		<!-- Utility -->
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<scope>provided</scope>
		</dependency>
	</dependencies>


	<build>
		<plugins>
			<plugin>
				<artifactId>maven-assembly-plugin</artifactId>
				<configuration>
					<archive>
						<manifest>
							<mainClass>uqac.groupe6.bankaccount.BankAccountApplication</mainClass>
						</manifest>
					</archive>
					<descriptorRefs>
						<descriptorRef>jar-with-dependencies</descriptorRef>
					</descriptorRefs>
				</configuration>
				<executions>
					<execution>
						<id>make-assembly</id> <!-- this is used for inheritance merges -->
						<phase>package</phase> <!-- bind to the packaging phase -->
						<goals>
							<goal>single</goal>
						</goals>
					</execution>
				</executions>
			</plugin>
		</plugins>
	</build>


</project>

Application.properties

Dans le fichier src/main/resources/application.properties

  • On spécifie le port sur lequel l'application doit être déployée
  • On spécifie l'ensemble des informations pour accéder au services de gestion de base de données
# Server configuration
server.port=8081

#PostgreSQL Configuration
spring.datasource.url=jdbc:postgresql://localhost:5432/account
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQL81Dialect

Architecture

Nous utilisons la Clean Architecture pour développer notre application. Nous obtenons ainsi le diagramme de classe simplifié suivant.

Package

Tout d'abord nous devons

Le domaine

Domain encapsulate Enterprise wide business rules. An entity can be an object with methods, or it can be a set of data structures and functions.

public class Account {
	private String name;

	private AccountType accountType;

	private Balance balance;
}
public class Balance {
	private double amount;
}
public enum AccountType {
	CHEQUE, EPARGNE
}

Service

This layer contains application specific business rules. It encapsulates and implements all of the use cases of the system. These use cases orchestrate the flow of data to and from the entities, and direct those entities to use their enterprise wide business rules to achieve the goals of the use case.

Le service métier, ici la gestion d'un compte bancaire est :

  • représenté par une interface AccountService
  • cette interface est appelée par notre client au travers du Contrôleur AccountController
  • Les informations transmises entre le Contrôleur et le service métier sont encapsulées dans un DTO.
  • On récupère les informations pertinantes contenu dans le DTO et on applique l'action demandée (e.g créer un nouvel utilisateur)
  • Certaine action comme créer un nouvel utilisateur necessite de faire appel à la base de donnée que nous appelerons au travers d'un Adaptateur AccountGateway

Data Transfert Object

A Data Transfer Object (DTO) is an object that is used to encapsulate data, and send it from one subsystem of an application to another.

Dans notre cas les DTOs vont permettre de transférer des informations entre le Contrôleur et le Service métier. Ainsi, le Contrôleur :

  1. Recupère les informations fournies par l'utilisateur (e.g. identifiant utilisateur, nom du compte, etc ...).
  2. Au lieu de transférer directement les informations au Service métier, il va les encapsuler dans un objet DTO.
  3. Transmet de DTO au Service métier

Exemple

Si on souhaite créer un nouveau compte bancaire :

  1. nous devons fournir au Contrôleur l'identifiant de l'utilisateur, le nom du compte à créer et le type du compte (Epargne ou Chèque). Ces informations seront dans le boby de notre requête http
  2. nous créons un nouvel objet de type AccountRequestModel en précisant la valeur des attributs en fonction du point précédent.
  3. le Contrôleur appelle la méthode accountService.create(accountRequestModel) qui accepte seulement un DTO

"DTO" et "Request/ResponseModel" représente la même notion.

RequestDTO

Il fait la communication du Controller vers le Service

public class AccountRequestModel {
	private Long idCustomer;
	private Long idAccount;
	private String name;
	private String accountType;
}

ResponseDTO

Il fait la communication du Service vers le Controller

public class AccountResponseModel {
	private String name;
	private String accountType;
	private double balanceAmount;
}

Note
Nous pourrions créer plusieurs DTOs requêtes et réponses afin d'éviter d'avoir des attributs null. En effet, lors de la création d'un nouveau compte bancaire l'attribut idAccount sera null. Mais dans la construction de notre application nous souhaitons rester simple et n'utiliser qu'un seul DTO.

Base de données

Pour pour persister des informations en base de données nous allons utiliser des librairies externes. Afin de protéger le coeur de notre application la communication avec la librairie externe se fera à travers d'un adaptateur. Nous avons choisis cette solution car :

  • Si la librairie externe utilisée modifie son interface alors seule la classe Adaptateur devra être adaptée.
  • Si nous décidons d'utiliser une autre librairie qui ne respecte pas les spécification JPA alors seule la classe Adaptateur devra être adaptée.

Ainsi avec cette option, le coeur restera inchangé quelques soient les choix et directive sur les librairies externes

Entités JPA

Dans notre application nous avons deux types d'entités

  • Les entités du domaine
  • Les entités JPA

Malgré leur forte similitude il est important deux les distinguer dans deux classes séparées. En effet, le coeur applicatif ne doit pas savoir comment les entités sont persistées (fichier, bases de données, etc ...). Ainsi, le lien entre les entités du domaine et les entités JPA sera fait par l'implémentation AccountGatewayIml

Ensuite, lorsque nous créons une entité JPA nous devons préciser :

  • que c'est une entité via l'annonation @Entity
  • la table où cette entité est persisté @Table(nom_table)
  • par la suite nous vous spécifier des options sur certain attributs :
    • @Id pour l'identifiant
    • @OneToOne lorsque nous avons une relation de dépendance entre deux tables
    • etc ...
@Entity
@Table(name = "account")
public class AccountJpaEntity {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private long id;

	@NonNull
	private Long customerId;

	@NonNull
	private String name;

	@NonNull
	private LocalDateTime createdAt;

	@OneToOne(cascade = { CascadeType.ALL })
	@NonNull
	private BalanceJPAEntity balanceJPAEntity;

	@Enumerated(EnumType.STRING)
	@NonNull
	private AccountTypeJPAEnum accountType;
}
@Entity
@Table(name = "balance")
public class BalanceJPAEntity {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private long id;

	@NonNull
	private double amount;

	@ElementCollection
	@CollectionTable(name = "transaction", joinColumns = @JoinColumn(name = "id"))
	@Column(name = "transactions")
	private List<Integer> transactionList;

	@NonNull
	private LocalDateTime lastUpdate;
}
public enum AccountTypeJPAEnum {
	CHEQUE, EPARGNE
}

Le Repository

Il orchestre l'entity manager pour la persistance en base de données. Pour créer notre propre repository nous devons implémenter l'interface JPARepository.

En implémentant cette interface nous n'avons pas besoin d'écrire les méthodes de base comme save, delete, getById, etc ... elles ont déjà été implémentées. Cependant si nous souhaitons des méthodes plus spécifique à notre problème alors nous devons les écrires.

Par exemple, nous avons définie une méthode qui permet lister tous les comptes d'un utilisateur

public interface AccountJpaRepository extends JpaRepository<AccountJpaEntity, Long> {
	List<AccountJpaEntity> findByCustomerId(Long idCustomer);

	Optional<AccountJpaEntity> findByCustomerIdAndName(Long idCustomer, String name);

	List<AccountJpaEntity> findByCustomerIdAndAccountType(Long idCustomer, AccountTypeJPAEnum accountType);
}

Ce qui est encore plus fort, c'est que nous n'avons même pas besoin d'implémenter cette classe. En effet, en écrivant bien le nom des méthodes find + By + something où le something un attribut dans nos entités JPA, le framwork est capable d'écrire automatiquement la requête SQL précise.

La Gateway

Elle traduit nos :

  • entités JPA en entités domaine
  • entités domaine en entités JPA
public interface AccountGateway {

	void create(Long idCustomer, String accountName, AccountType accountType);
	void updateName(Long accountId, String newAccountName);
	void delete(Long idAccount);
	Account findByAccountId(Long idAccount);
	Account findByCustomerIdAndByName(Long idCustomer, String name);
	Account findByCustomerIdAndAccountId(Long idCustomer, Long idAccount);
	List<Account> findAllAccounts(Long idCustomer);
	List<Account> findByCustomerIdAndAccountType(Long customerId, AccountType accountType);
}

La méthode create

Objectif Créer un nouveau compte bancaire à un utilisateur. Elle prend donc en paramètre l'identifiant du client, le nom du compte à créer ainsi que son type.

  1. Crée l'ensemble des entités JPA nécessaire en utilisant les paramètres
  2. Appelle la méthode save disponible dans AccountJPARepository (car elle implémente JPARepository)
@Component
public class AccountGatewayImpl implements AccountGateway {

	private final AccountJpaRepository accountJpaRepository;

	@Override
	public void create(Long idCustomer, String accountName, AccountType accountType) {
		accountJpaRepository.save(
            new AccountJpaEntity(idCustomer, accountName, LocalDateTime.now(),
				new BalanceJPAEntity(0.0, LocalDateTime.now()), AccountTypeJPAEnum.valueOf(accountType.name())));
	}
    ...
}

La méthode findAllAccounts

Objectif Récupérer tous les compte bancaires qu'un utilisateur possède

  1. Appeler la méthode findByCustomerId créée précédement dans AccountJpaRepository
  2. Convertir chaque AccountJPAEntity en Account. En effet, la partie métier ne traite que les entités du domaine
  3. Retourner la liste
@Override
public List<Account> findAllAccounts(Long idCustomer) {
    List<Account> toReturn = new ArrayList<>();
    for (AccountJpaEntity accountJpaEntity : accountJpaRepository.findByCustomerId(idCustomer)) {
        toReturn.add(accountJpaToDomain(accountJpaEntity));
    }
    return toReturn;
}

private Account accountJpaToDomain(AccountJpaEntity jpaEntity) {
    return Account.builder().name(jpaEntity.getName())
            .accountType(AccountType.valueOf(jpaEntity.getAccountType().name()))
            .balance(balanceJpaToDomain(jpaEntity.getBalanceJPAEntity())).build();
}

Service Métier

Il fait le lien avec les trois parties :

  • Le Controller d'où il reçoit une requête
  • Le Domaine qu'il doit exploiter / modifier
  • La base de données où sont stocker les informations
public interface AccountService {

	void create(AccountRequestModel requestModel) throws AccountNameAlreadyExistException;

	void update(AccountRequestModel requestModel) throws AccountDoestExistException;

	void delete(AccountRequestModel requestModel) throws AccountDoestExistException;
	AccountResponseModel getOneAccount(AccountRequestModel requestModel);

	List<AccountResponseModel> getAllAccounts(AccountRequestModel requestModel);

	List<AccountResponseModel> getAllAccountType(AccountRequestModel requestModel);
}

Sur l'implémentation nous devons spécifier l'annoation @Service

@Service
public class AccountServiceImpl implements AccountService {
    ...
}

Créer un compte bancaire

Pour créer un nouveau compte bancaire à l'utilisateur nous devons :

  • vérifier que le compte n'existe pas
    • donc vérifier en base de données si nous avons quelque chose
  • vérifier que le type de compte souhaité existe
    • vérifier que le type saisi est soit Cheque soit Epargne
  • Créer le compte
@Override
public void create(AccountRequestModel requestModel) throws AccountNameAlreadyExistException {
    // Récupérer en base de données un compte bancaire via l'identifiant du client et le nom du compte
    Account accountAlreadyExist = accountGateway.findByCustomerIdAndByName(requestModel.getIdCustomer(),
            requestModel.getName());

    if (accountAlreadyExist != null) {
        throw new AccountNameAlreadyExistException(
                "Account with name " + requestModel.getName() + " already exist");
    }

    // Vérifier si le type de compte est valide
    if (!requestModel.getAccountType().equals(AccountType.CHEQUE.name())
            && !requestModel.getAccountType().equals(AccountType.EPARGNE.name())) {
        throw new IllegalArgumentException("The account type " + requestModel.getAccountType() + " doesn't exist");
    }

    // Créer le compte bancaire en appelant la gateway et en transmettant les paramètres
    accountGateway.create(requestModel.getIdCustomer(), requestModel.getName(),
            AccountType.valueOf(requestModel.getAccountType()));
}

Note Afin de simplifier la création des microservices par ordre de difficulté et étant donné que le microservice pour la création d'un nouvelle utilisateur n'a pas encore été développé nous ne vérifions pas ici si l'utilisateur existe (customerID).

Contrôleur

Il recoit les requête WEB et transmet l'information à la partie métier. Nous y définissons l'ensemble des points d'entrées de notre application.

API

  • Chaque point d'entrée commence par /account suivi de
    • /create pour créer un compte
    • /update/{id} pour mettre en jour un compte
    • /delete/{id} pour supprimer un compte
    • /customer/{id} récupérer tous les au comptes du client id
    • /customer/{idCustomer}/account/{idAccount} récupérer un compte spécifique idAccount du client idCustomer
    • /customer/{idCustomer}/type/{accountType} récupérer tous les comptes en fonction du type CHEQUE ou EPARGNE

Pour ce faire la classe AccountController possèdera :

  • l'annotation @RestController
  • l'annotation @RequestMapping("/account")

Créer un compte

  1. Prend en paramètre un objet représentant un AccountRequestModel
  2. Appel du service métier
    1. Si succès alors on renvoie le status 201
    2. Si erreur alors on renvoie le status 403
@PostMapping("/create")
public ResponseEntity create(@RequestBody AccountRequestModel requestModel) {
    try {
        // Appel de la partie métier
        accountService.create(requestModel);
        return ResponseEntity.status(HttpStatus.CREATED).body("New account " + requestModel.getName() + " created");
    } catch (AccountNameAlreadyExistException e) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage());
    }
}

Tester

Notre application est prête à être testée. La classe principale composée de la méthode main() est très classique.

@SpringBootApplication
@RestController
public class BankAccountApplication {

	public static void main(String[] args) {
		SpringApplication.run(BankAccountApplication.class, args);
	}

	@GetMapping
	public String hello() {
		return "Welcome to Bank Account";
	}
}

Lancer notre application

Il suffit de lancer le programme Java via Run As -> Java Application. Un serveur accessible à l'adresse suivant localhost:8081 est déployé.

Création automatique de la table

Lorsqu'on lance le serveur, Spring va nous créer les tables relatives au compte bancaire, à la balance et aux transaction. On retrouve la log suivante

Hibernate: 
    
    create table account (
       id  bigserial not null,
        account_type varchar(255),
        created_at timestamp,
        customer_id int8,
        name varchar(255),
        balancejpaentity_id int8,
        primary key (id)
    )
Hibernate: 
    
    create table balance (
       id  bigserial not null,
        amount float8 not null,
        last_update timestamp,
        primary key (id)
    )
Hibernate: 
    
    create table transaction (
       id int8 not null,
        transactions int4
    )
Hibernate: 
    
    alter table account 
       add constraint FK4ph46xeruid24i50n89i428j6 
       foreign key (balancejpaentity_id) 
       references balance
Hibernate: 
    
    alter table transaction 
       add constraint FKrn3ygljg7joxal9hsu695ltr6 
       foreign key (id) 
       references balance

Tester notre application

On peut utiliser par exemple Postman pour effectuer des requêtes vers notre application.

Diagramme de séquence

On représence ci-desosus l'ensemble des étapes necessaire à la création d'un compte bancaire

  1. L'utilisateur appelle le contrôleur en lui transmettent un body contenant toutes les informations à enregistrer
  2. Le Contrôleur transmet au Service les informations sous la forme d'un DTO
  3. Le Service transmet à l'adapteur de la base de données
  4. L'adapteur de la base de données convertit les informations reçu en entité JPA puis appelle le Repository qui assurera la persistance

Introduction

L’authentification est un processus permettant au système de s’assurer de la légitimité de la demande d’accès faite par une entité […] afin de l'autoriser à accéder à ressources du système.

Dans ce chapitre, nous nous interessons donc à l'enregistrement et la connexion d'un utilisateur. En soit, gérer l'authentification

Analyse du besoin

Des pages sont accéssible sans être connectés. C'est le cas de la page de login et de registration. D'autres pages, demande à l'utilisateur d'être connecté, comme la page restricted.

Nous devons donc mettre en place un système permettant d'accéder à des ressources non protégées et à une ressource protégée.

Configuration

Pom

Spring Boot Starter Security est un module pratique et efficace pour ajouter des fonctionnalités de sécurité à une application Spring Boot avec une configuration minimale.

<!-- Spring -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Application.properties

Dans le fichier src/main/resources/application.properties

  • On spécifie le port sur lequel l'application doit être déployée
  • On spécifie l'ensemble des informations pour accéder au services de gestion de base de données
# Server configuration
server.port=8083

#PostgreSQL Configuration
spring.datasource.url=jdbc:postgresql://localhost:5432/customer
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQL81Dialect

Architecture

On reprend la même architecture que dans le chapitre précédent. Nous y avons juste rajouter dans la couche métier la classe SecurityConfig qui permet de mettre en place l'authentification dans notre application

Domaine

public class Customer {

	private String email;
	private String password;
	private String firstName;
	private String lastName;
	private String username;
	private String phoneNumber;
	private Role role;

}
public enum Role {
	USER("User"), ADMIN("Admin");

	private final String value;

	private Role(String value) {
		this.value = value;
	}

	public String getValue() {
		return value;
	}
}

DTO

Nous décisons ici de créer deux DTO pour les requêtes :

  • Un pour la création d'un compte utilisateur
  • Un pour la demande de connexion
public class RegisterCustomerRequestDTO {
	private String email;
	private String password;
	private String matchedPassword;
	private String firstName;
	private String lastName;
	private String phoneNumber;

}
public class LoginRequestDTO {
	private String email;
	private String password;
}

On remarque bien la différence entre les DTOs et le Domaine. Dans le premier DTO, nous avons deux fois le mot de passe. L'utilisateur va être amené à le saisir deux fois mais dans le Domaine nous ne le spécifions qu'une seule fois (comme en base de donnée). En effet, dans la partie service métier nous allons vérifier que les deux mot de passes sont égaux.

Base de données

Entité JPA

Nous n'avons qu'une seule entité JPA qu'est l'utilisateur. De même que pour les DTOs on remarque que l'entité JPA n'est pas exactement la même que l'entité du Domaine. En effet, il peut être intéressant de connaitre la date de création/de mise à jour du compte en base sans forcément l'exploiter dans notre application.

@Entity
@Table(name = "customer")
public class CustomerJpaEntity {
	@SequenceGenerator(name = "users_sequence", sequenceName = "users_sequence", allocationSize = 1)
	@Id
	@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "users_sequence")
	private int id;

	@NonNull
	@Column(name = "first_name")
	private String firstName;

	@NonNull
	@Column(name = "last_name")
	private String lastName;

	@NonNull
	@Column(name = "email", unique = true)
	private String email;

	@NonNull
	@Column(name = "password")
	private String password;

	@NonNull
	@Column(name = "mobile", unique = true)
	private String mobile;

	@NonNull
	@Enumerated(EnumType.STRING)
	private Role role;

	@Column(name = "created_at", updatable = false)
	@NonNull
	private LocalDateTime createdAt;

	@Column(name = "updated_at")
	@NonNull
	private LocalDateTime updatedAt;

}

Le Repository

Il implémente l'interface JPARepository est ajoute des méthodes spécifiques.

@Repository
public interface CustomerJpaRepository extends JpaRepository<CustomerJpaEntity, Long> {
	Optional<CustomerJpaEntity> findByEmail(String email);

	Optional<CustomerJpaEntity> findByMobile(String mobile);

	Optional<CustomerJpaEntity> findById(Long idCustomer);
}

Note
Nous avons créer la méthode findById car nous souhaitons retourner un Optional. Néanmoins, JPA fourni la méthode getReferenceById qui permet de récupérer une entité de type Entity (i.g. CustomerJpaEntity).

Service Métier

Gestion de l'authentification

La gestion de la sécurité étant le sujet principal de ce chapitre sur l'authentification j'ai décidé de créer une section séparée qui lui sera dédiée. Mais à noter, que l'ensemble des classes développées iront dans le package /usecase/security. En effet, la gestion de la sécurité fait bien partie de notre domaime des cas d'utilisations / services.

Architecture Spring Security

Avant de coder la mise en place de la sécurité dans notre application, il est important de comprendre l'architecture mise en place par Spring pour réaliser l'authentification.

Comment accéder à une ressource privée

L'authentification permet de restreindre l'accès une ressource qu'aux utilisateurs authentifiés. Pour ce faire Spring utilise le système suivant

  • Lorsqu'une requête arrive, elle est interceptée pour authentifiée ou vérifier si l'utilisateur est authentifiée.
    • Si l'utilisateur ne réussit pas cette étape, alors une exception est levée par le bloc d'authentification. Par conséquence, le Contrôleur n'est pas invoqué, l'utilisateur n'accède pas à la ressource.
    • Si l'utilisateur est correctement authentifié, alors l'authentification déclenche l'appel au Contrôleur qui va retourner le contenu demandé.

Détail du bloc d'authentification

Après avoir décrit le fonctionnement (reussite/echec) regardons en détail le fonctionnement du bloc d'authentification. C'est la partie la plus complexe, mais sa compréhension est priomardiale.

  1. Lorsque la requête arrive elle est tout d'abord intercepté par la Security Filter chain. C'est une série de filtre qui vont vérifier si l'utilisateur peut accéder à la ressource.
  2. Parmi ces filtres, un permet de vérifier l'authentification. Pour ce faire il va appeler un AuthenticationManager en lui fournissant un objet de type UsernamePasswordAuthenticationToken construit via la requête.
  3. L'AuthenticationManager va appeler un Provider (e.g. AuthenticationProvider) qui retrouver l'utilisateur (chez nous en base de donnée). Pour ce faire, on doit lui injecté lors de son initialisation un PasswordEncoder et un UserDetailService.
  4. Finalement si l'utilisateur est bien présent en base de données, une Authentification est retournée. Elle est stockée dans le SecurityContext
  5. Et le dernier filtre passe la main au Contrôleur

Définitions

Security Filter Chain

Lorsque la requête est envoyé par l'utilisateur elle est interceptée par la Security Filter Chain qui est composée d'une série de filtre.

L'Authentication Manager

Il crée un Authorization ou lève une exception.

Si l'authentification n'est pas réussite une exception est levée, par conséquence le contrôleur n'est pas appelé et la ressource n'est pas accessible.

public interface AuthenticationManager {
    Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

. Cette interface est composée d'une seule méthode qui prend en paramètre un Authentication fournie est valide :

  • Renvoie une Authentication (normalement avec authenticated=true) si elle est confirmée
  • Lancer l'exception AuthenticationException si elle n'a pas pu être vérifiée

A noter, que notre requête doit donc être transformé en un objet de type Authentication, par exemple UsernamePasswordAuthenticationToken pour pouvoir être passé à l'AuthenticationManager.

DAOAuthenticationProvider

Permet d'authentifier via un username et un password

Pour fonctionner il necessite qu'on lui injecte lors de sa construction un PasswordEncoder ainsi qu'un UserDetailService.

UserDetailService

Charge un utilisateur (nous en base de données) et le retourne sous la forme d'un UserDetails

public interface UserDetailService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

Pour pouvoir récupérer un utilisateur en base de données, il va falloir lui "fournir l'accés" et coder nous même le corps de la fonction. En d'autre terme, cette fonction va être implémenté dans notre ``CustomerServiceImploù elle va appeler la gateway puis transformer l'entité domaine enUserDetails`.

Implémentation de l'architecture

Nous avons vu le fonctionnement de Spring Security, nous allons maintenant mettre en oeuvre les trois différente partie de l'authentification :

  • Le manager AuthenticationManager
  • Le provider DAOAuthenticationProvider
  • Le security filter chain

Pour ce faire, nous allons développée une classe SecurityConfig que nous allons annoter avec @Configuration et @EnabledWebSecurity

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    /* manager */
    /* provider */
    /* security filter chain */
}

Créer un AuhenticationManager

L'implémentation par défaut, si nous ne précisons pas le AuthentificationManager Spring utilisera le ProviderManager. Ainsi le code permettant de fournir un AuthentificationManager à notre programme reste simple.

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

Créer un DAOAuthenticationProvider

Le DAOAuthenticationProvider à une dépendance vers un PasswordEncore et vers un UserDetailService que nous devons lui founir

@Bean
public AuthenticationProvider authenticationProvider(UserDetailsService userDetailsService , PasswordEncoder passwordEncoder) {
    DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
    daoAuthenticationProvider.setPasswordEncoder(passwordEncoder);
    daoAuthenticationProvider.setUserDetailsService(userDetailsService);
    return daoAuthenticationProvider;
}

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

Etant donné que nous avons besoin d'un passwwordEncoder nous devons créer un Bean spécifique. Concernant l'userDetailService il sera injecté via l'annotation que nous positionnerons sur la classe.

Créer une Security Filter Chain

Il ne nous reste plus qu'à définir la Security Filter Chain.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.csrf().disable()
                    .authorizeHttpRequests()
                    .antMatchers("/customer/auth/*").permitAll()
                    .anyRequest().authenticated();

        // Construction de la chaine
        return httpSecurity.build();
  }
}

Autoriser des requêtes sans authentification

Pour que l'utilisateur est la possibilité de créer un compte ou de se connecter nous devons autoriser les requête vers ces deux end-points :

  • .antMatchers("/customer/auth/*").permitAll() permet de dire que toutes requêtes provevant de /customer/auth/* n'ont pas besoin d'être authentifiées :
    • /customer/auth/login
    • /customer/auth/register

Interdire des requête aux utilisateurs non connectés

Néanmois, nous souhaitons que les autres pages soient accessibles seulement aux utilisateur connectés :

  • .anyRequest().authenticated() spécifie que seuls les utilisateurs authentifiés peuvent avoir accès à toutes autres ressources

Conclusion

En soit, cette classe nous permet de personnaliser comment Spring va gérer l'authentification. En effet, Spring fournit le mécanisme mais nous sommes libre de :

  • Préciser le fonctionnement de la SecurityFilterChain
  • Préciser l'AuthenticationManager à utiliser
  • Fournir un ou plusieurs AuthenticationProvider

UserDetailService

UserDetailsService is used by DaoAuthenticationProvider for retrieving a username, a password, and other attributes for authenticating with a username and password

Pour assurer l'authentification il faut que notre classe RegisterCustomerServiceImpl implémente en plus l'interface UserDetailsService. Cela va également impliqué de redéfinir et d'ajouter la méthode loadUserByUsername().

Note
Dans notre cas le critère d'uniticité est l'adresse email

public interface RegisterCustomerService extends UserDetailsService {
	void register(RegisterCustomerRequestModel dto) throws ...;

	void update(RegisterCustomerRequestModel dto, Long id) throws ...;
}

@Service
public class RegisterCustomerServiceImpl implements RegisterCustomerService {

	...

	@Override
	public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        // Récupération de l'utilisateur en BDD
		Customer customer = customerRegisterGateway.findByEmail(email);

		// Conversion d'un customer en UserDetail
		User user = new User(customer.getEmail(), customer.getPassword(),
				Collections.singletonList(new SimpleGrantedAuthority(customer.getRole().name())));

		return user;
	}
}

1- Nous allons récupérer l'utilisateur en base de données en fonction de son adresse mail 2- Puis convertir l'objet Customer retourné en un UserDetail qui est la représentation d'un utilisateur en Spring Security

Contrôleur

Il recoit les requête WEB et transmet l'information à la partie métier.

API

Chaque point d'entrée commence par customer suivi de

  • /registration pour créer un nouveau compte utilisateur
  • /login pour se connecter
  • /update/{id} pour mettre à jour son compte (principalement le mot de passe)
  • /restricted qui est une page accessible uniquement si on est authentifié

Attributs

@RestController
@RequestMapping("/customer")
public class CustomerController {

    // Fourni par le Bean développé dans SecurityConfig
    @Autowired
    private AuthenticationManager authenticationManager;

    private RegisterCustomerService registerCustomerService;

    ...
}

S'enregistrer

L'enregistrement d'un nouvel utilisateur ne pose pas de problème particulier. Nous appelons simplements le service en lui fournissant le DTO.

@PostMapping("/register")
public ResponseEntity create(@RequestBody RegisterCustomerRequestDTO requestModel) {

    try {
        // Appel service métier en fournissant le DTO
        registerCustomerService.register(requestModel);

        return ResponseEntity.status(HttpStatus.CREATED).body("New account created");
    } catch (RegistrationMailAlreadyExist | RegistrationPhoneNumberAlreadyExist | RegistrationMDPmatch e) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage());
    }
}

Se connecter

Néanmois la connexion est un peu plus complexe à mettre en oeuvre. Elle se traduit par le code suivant où nous devons

  1. Créer un token d'authentification avec les informations founies par l'utilisateur. Pour rappel, la classe en charge de l'authentification est AuthenticationManager est son unique méthode accepte un objet de type Authentication (e.g UsernamePasswordAuthenticationToken).
  2. Demander au gestionnaire d'authentification AuthenticationManager si les idenfiants sont corrects
  3. Mettre à jour le SecurityContext avec les informations du client maintenant authentifié
@PostMapping("/auth/login")
public ResponseEntity<String> login(@RequestBody LoginRequestDTO loginDTO) {
    try {
        /* Créer un objet de type Authentication */
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginDTO.getEmail(),
                loginDTO.getPassword());

        /* Appeler l'authenticationManager en passant l'objet précédent */        
        Authentication auth = authenticationManager.authenticate(token);

        /* Ajouter l'authentification au SecurityContext */
        SecurityContextHolder.getContext().setAuthentication(auth);
        
        return ResponseEntity.status(HttpStatus.ACCEPTED).body("User connected");
    } catch (AuthenticationException e) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage());
    }
}

Diagramme de séquence

Le diagramme de séquence simplifié reprend les étapes réalisées en interne par le framework Spring pour réaliser une demande d'authentification

  1. L'utilisateur effectue une requête sur /auth/login
  2. La première instruction dans le Contrôleur crée un token d'authentification
  3. Les informations saisies sont envoyées à un AuthenticationManager
  4. Qui retransmets les informations à un objet de type AuthentificationProvider
  5. AuthentificationProvider va :
    1. Appeler la méthode retrieveUser qui à partir du token d'authentification va retourner un objet de type UserDetail. L'UserDetail est l'objet Spring qui fournit des informations de base sur l'utilisateur.
    2. Appeler la méthode loadUserByUsername sur un objet de type UserDetailService. Et, précédement nous avons créer notre propre implémentation RegisterCustomerServiceImpl
    3. Ainsi, cette méthode (dont nous avons définie le fonctionnement juste avant) va aller chercher en base de données l'utilisateur et le renvoyer sous sa forme UserDetail
  6. Nous avons donc récupérer notre utilisateur (il existe bien !). AuthentificationProvider va donc crée un objet de type Authentification en précisant l'attribut authenticated = true
  7. Ce dernier objet et remonté jusqu'au Contrôleur qui va l'ajouter dans le SecurityContext

Page avec restriction

Il n'y a aucune indication dans le Contrôleur qui informe que la page necessite une authentification. La gestion a été définie dans la classe SecurityConfig.

@GetMapping("/restricted")
public String retricted() {
    return "Welcome to Restricted page";
}

Authentification Basic

Nous faisons une brève aparté sur l'authentification basic car elle permet de comprendre le fonctionnement des filtres et comment ils sont été mis en oeuvre dans Spring.

L'authentification basique fonctionne comme suit :

  1. Le client demande d'accéder à une ressource et envoie ses identifiants de connexion sous forme d'une chaîne de caractères encodée en base64 qui contient le nom d'utilisateur et le mot de passe séparés par un deux-points.
  2. Le serveur décode ensuite la chaîne encodée en base64 pour extraire le nom d'utilisateur et le mot de passe, puis vérifie si ces informations sont valides.
  3. Si elles sont valides, l'utilisateur est considéré comme authentifié et peut accéder à la ressource protégée.

Note
L'authentification Basic n'est pas sécurisée car les informations d'identification sont envoyées en clair dans l'en-tête HTTP.

Security Filter Chain

Nous devons mettre à jour notre Security Filter Chain pour qu'elle prenne en compte l'authentification basic en rajoutant les deux instructions suivantes

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.
        ...
        .and()
        .httpBasic(); /* authentication basic */
  }

Mise en garde

Quelques tutoriel appèlent systémentatiquement .httpBasic(). Or, nous en avons besoin uniquement lorsque nous réalisation une authentification basic. Dans le chapitre précédent, la connexion a été codée manuellement. En effet, dans la méthode login du RegisterController nous préciserons :

  • Comment créer UsernameDetailService
  • L'appel à l'AuthentificationManager
  • L'ajout au SecurityContext

En soit, si nous précisons Basic cela signifie qu'on n'a pas à coder les morceaux précédent. BasicAuthenticationFilter s'occupe d'appeler l'AuthenticationManager en lui fournissant l'Authentication

Controleur

Nous pouvons nous passer de la page /auth/login, car l'utilisateur devra saisir ces identifiant pour se connecter à chacune des pages. Ainsi pour accéder à restricted il devra donner son identifiant et son mot de passe.

@GetMapping("/restricted")
public String retricted() {
    return "Welcome to Restricted page";
}

Basic authentification avec Spring

La documentation Spring explique très bien l'authentification Basic.

Spring fourni par défaut :

  • un filtre BasicAuthenticationFilter intercepte la requête
  • qui va crée le UsernamePasswordAuthenticationFilter
  • qui va appeler AuthenticationManager. On retombe sur le diagramme de séquence décrit précédement, avec un appel au DaoAuthenticationProvider
  • pour au final ajouter l'authentification au SecurityContext

Conséquence
Ainsi, l'authentification (le login) est assuré en interne par Spring, nous n'avons pas besoin de coder de méthode login().

Diagramme de séquence

StackTrace

  • Nous avons bien un ensemble de filtre exécutés, dont le BasicAuthenticationFilter.
  • Qui va faire appel à une intance de AuthenticationManager, en l'occurance ProviderManager
  • Qui lui, va appeler le DaoAuthenticationProvider afin de fournir une Authentication

L'invocation du contrôleur est réalisée automatiquement après, une fois la Security Filter Chain "validée", c'est-à-dire que l'utilisateur est bien authentifié.

Authentification JWT

Nous avons vu comment mettre en place une authentification dans Spring Security. Maintenant nous souhaitons mettre en place une authentification avec un token JWT.

Fonctionnement JWT

Les JSON Web Tokens (JWT) sont un moyen sécurisé de transmettre des informations entre deux parties. Ils sont généralement utilisés pour les applications Web et les services RESTful.

Voici comment se déroule l'authentification avec JWT entre un client et un serveur

  1. Authentification du client : Le client fournit ses identifiants (par exemple, nom d'utilisateur et mot de passe) au serveur d'authentification pour s'authentifier.
  2. Création du JWT: Si l'authentification est réussie, le serveur d'authentification crée un JWT qui contient les informations d'identification du client.
  3. Envoi du JWT au client : Le serveur d'authentification envoie le JWT au client, qui peut maintenant l'utiliser pour accéder aux ressources protégées sur le serveur de ressources.
  4. Envoi du JWT au serveur : Le client envoie le JWT au serveur de ressources dans l'en-tête Authorization de la requête HTTP.
  5. Vérification du JWT : Le serveur de ressources vérifie la signature du JWT. Si la signature est valide, le serveur de ressources extrait les informations d'identification du client à partir du JWT et vérifie que le client est autorisé à accéder à la ressource demandée. Si la vérification réussit, la ressource est renvoyée au client.
  6. Renvoie de la ressource : Le serveur envoie au client la ressource demandée

Nous avons donc deux étapes biens distincte, ce qui va impacté la rédaction (ou plutot le fonctionnement) de notre code.

Evolution de l'architecture

  1. Dans la première architecture :
    • L'authentification était assurée par la méthode login()
    • La chaine de sécurité vérifiée si l'utilisateur était contenu dans le SecurityContext et si aucune exception n'était levée, alors l'utilisateur pouvait accéder à /restricted
  2. Avec l'authentification via Basic :
    • Nous n'avons plus besoin de la méthode login()
    • L'utilisateur doit fournir ces identifiants à chaque appel
    • La Security Filter Chain avec BasicAuthenticationFilter autorisée ou non l'accès à la ressource protégée.
  3. Avec l'authentification via JWT
    • Nous avons besoin d'une méthode login() pour founir un JWT token
    • Nous avons besoin de créer notre propre filtre pour vérifier si le token fourni par l'utilisateur est valide

Architecture

Particularité

Il faut distinguer deux types de requêtes :

  • Toutes les requêtes (e.g. /restricted)
  • La requête de demande de connexion /auth/login

Toutes requêtes
Toutes les requêtes sont interceptées par la Security Filter Chain. Lorsque nous exécuterons une requête classique où l'utilisateur est connecté alors la requête passera dans le filtre JWTAuthenticationFilter.

Ce filtre aura pour but de vérifier si le token fourni est valide. Dans le cas contraire, il reverra une exception avec 403 UNAUTHORIZE

Requête de connexion
La requête de connexion /auth/login est aussi interceptée par la Security Filter Chain. Cependant, JWTAuthenticationFilter traite que les requête ayant un token JWT (nous allons le coder tel quel). Or, quand on souhaite se connecter nous n'avons de token JWT.

Par conséquence, notre requête qui contient email et password arrive au niveau de la méthode login() qui va devoir appeler l'AuthenticationManager et rajouter le token JWT obtenu au SecurityContext.

Donc le corp de la méthode login() ne sera pas vide, il devra générer un token JWT et le renvoyer au client

Configuration

Nous devons ajouter des dépendances permettant créer et vérifier des JSON Web Tokens (JWTs)

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.2</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.2</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-gson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
    <version>0.11.1</version>
    <scope>runtime</scope>
</dependency>

Generer token

Nous devons d'abord créer une classe permettant de générer un token JWT. Pour ce faire. Je m'appuis sur des exemples fournis sur internet.

@Component
public class JWTGenerator {

    private static final int expireInMs = 300 * 1000;

    private final static Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);

    public String generate(Authentication authentication) {
        String username = authentication.getName();

        return Jwts.builder().setSubject(username).setIssuer("adrien").setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expireInMs)).signWith(key).compact();
    }

    public boolean validate(String token) {
        if (getUsername(token) != null && isExpired(token)) {
            return true;
        }
        return false;
    }

    public String getUsername(String token) {
        Claims claims = getClaims(token);
        return claims.getSubject();
    }

    public boolean isExpired(String token) {
        Claims claims = getClaims(token);
        return claims.getExpiration().after(new Date(System.currentTimeMillis()));
    }

    private Claims getClaims(String token) {
        return Jwts.parser().setSigningKey(key).parseClaimsJws(token).getBody();
    }
}

JWTAuthenticationFilter

Créer le filtre

@Component
class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Autowired
    private JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
            FilterChain filterChain) throws ServletException, IOException {

        // On récupère le token contenu dans le Header
        final String authorizationHeader = httpServletRequest.getHeader("Authorization");
        if (authorizationHeader == null || authorizationHeader.isEmpty() || !authorizationHeader.startsWith("Bearer")) {
            // S'il n'existe pas alors on passe au filtre suivant
            // C'est le cas pour le /login
            filterChain.doFilter(httpServletRequest, httpServletResponse);
            return;
        }

        final String token = authorizationHeader.split(" ")[1].trim();
        if (!jwtUtil.validate(token)) {
            // Si le token est invalide, alors on passe au filtre suivant
            filterChain.doFilter(httpServletRequest, httpServletResponse);
            return;
        }

        // Le token est valide
        String username = jwtUtil.getUsername(token);
        
        // On va créer une Authentication
        UsernamePasswordAuthenticationToken upassToken = new UsernamePasswordAuthenticationToken(username, null,
                new ArrayList<>());
        upassToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));

        // On passe l'Authentication au Security Context
        SecurityContextHolder.getContext().setAuthentication(upassToken);

        // On appelle le filtre suivant
        filterChain.doFilter(httpServletRequest, httpServletResponse);
    }
}

Ajouter le filtre à la SecurityFilterChain

On ajoute notre JwtAuthenticationFilter avant le filtre UsernamePasswordAuthenticationFilter.

@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity...

    httpSecurity.addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

    return httpSecurity.build();
}

Cas d'une requête quelconque

Dans le cas d'une requête quelconque nous :

  1. Vérifions quelle contienne une Authorization
  2. Vérifions que le token fourni est valide
  3. Si tel est le cas, alors nous créons une Authentication que nous ajoutons au SecurityContext
  4. Nous passons au filtre suivant
  5. Une fois tous les filtres exécutés, nous arriverons dans la methode exécutons la méthode appelée, par exemple /restricted

Cas d'une requête de connexion

Dans le cas d'une requête /auth/login nous n'avons pas de token donc nous rentrons dans le premier if ce qui va exécuter les filtres suivants.

Une fois tous les filtres exécutés, nous arriverons dans la methode login() où nous devrons :

  • Créer un objet de type Authentication
  • Appeler le AuthenticationManager
  • Ajouter l'AuthenticationManager au SecurityContext
  • Et, créer et renvoyer le JWT Token

Contrôleur

Demande de connexion

Afin d'accepter le nouveau système d'authentification nous devons mettre à jour notre contrôleur. Nous en profitons également pour créer un ResponseModel qui sera renvoyé lors de la demande de connexion.

public class LoginResponseModel {
    private String accessToken;
    private String tokenType = "Bearer ";

    public LoginResponseModel(String accessToken) {
        this.accessToken = accessToken;
    }
}
@PostMapping("/auth/login")
public ResponseEntity<LoginResponseModel> login(@RequestBody LoginRequestModel loginDTO) {
    try {
        /* Créer un objet de type Authentication */
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginDTO.getEmail(),
                loginDTO.getPassword());

        /* Appeler l'authenticationManager en passant l'objet précédent */        
        Authentication auth = authenticationManager.authenticate(token);

        /* Ajouter l'authentification au SecurityContext */
        SecurityContextHolder.getContext().setAuthentication(auth);

        /* Création du JWT token */
        String JWTtoken = jwtGenerator.generateToken(auth);

        /* Renvoie du JWT token au client */
        return new ResponseEntity<>(new LoginResponseModel(JWTtoken), HttpStatus.OK);
    } catch (AuthenticationException e) {
        return new ResponseEntity<>(null, HttpStatus.FORBIDDEN);
    }
}

Page avec restriction

Il n'y a aucune indication dans le Contrôleur qui informe que la page necessite une authentification. Pour pouvoir accéder à cette page l'utilisateur devra fournir le token retourné par login(), et la JWTFilterChain s'assuera de sa validité

@GetMapping("/restricted")
public String retricted() {
    return "Welcome to Restricted page";
}

Diagramme de séquence

Requête de connexion

Dans le cas du /login, la requête est intercepté par la Security Filter Chain et à un moment arrive dans le JWTFilterChain mais est bloqué par le if qui vérifie si un token existe (ce qui n'est pas le cas). Donc on exécute les filtres suivants (non représentés). Et on arrive enfin à la méthode CustomerController#login() dont nous avons déjà représenté le diagramme de séquence.

Autres requêtes

Dans le cas d'une autre requête (e.g /restricted) la requête est intercepté par la Security Filter Chain et à un moment arrive dans le JWTFilterChain.

  1. Si le token est invalise, alors une exception est levée
  2. Si le token est valide, alors nous ajoutons l'utilisateur au SecurityContext, ce qui autorise l'accès à la ressource

Il est possible d'avoir plusieurs AuthenticationProvider afin de gérer différents type d'authentification :

  • Par mot de passe
  • OAuth2
  • LDAP
  • custom

Ainsi, dans cette partie nous allons voir comment configurer notre application pour qu'elle accepte à la fois l'authentification par :

  • mot de passe en retrouvant l'utilisateur en base de données
  • en mémoire, nous allons enregistrer un utilisateur en mémoire directement
  • en customisant notre propre provider

AuthenticationManager

Nous devons donc configurer notre AuthenticationManager pour qu'il accepte différent type d'authentification. Pour ce faire nous allons nous aider de la classe AuthenticationManagerBuilder

class SecurityConfig {
    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Autowired
    private CustomAuthenticationProvider customAuthProvider;

    @Bean
    public AuthenticationManager authManager(HttpSecurity http) throws Exception {
        AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);
        authenticationManagerBuilder.userDetailsService(userDetailsService);
        authenticationManagerBuilder.inMemoryAuthentication()
                .withUser("memuser")
                .password(passwordEncoder().encode("pass"))
                .roles("USER");
        authenticationManagerBuilder.authenticationProvider(customAuthProvider);
        return authenticationManagerBuilder.build();
}
}
  • authenticationManagerBuilder.userDetailsService(userDetailsService); permet d'ajouter l'authentification par user/password via la configuration d'un userDetailsService qui ira chercher en base de données les informations. Derrière cette méthode se cache la création de l'objet DaoAuthenticationProvider
  • authenticationManagerBuilder.inMemoryAuthentication() permet d'ajouter un utilisateur en mémoire directement
  • authenticationManagerBuilder.authenticationProvider(customAuthProvider) permet de créer notre propre provider

CustomAuthProvider

Classe simpliste, nous enregistrons les informations (user/password) directement dans le code afin de simplifier

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
    @Override
    public Authentication authenticate(Authentication auth)
            throws AuthenticationException {
        String username = auth.getName();
        String password = auth.getCredentials()
                .toString();

        if ("externaluser".equals(username) && "pass".equals(password)) {
            return new UsernamePasswordAuthenticationToken
                    (username, password, Collections.emptyList());
        } else {
            throw new
                    BadCredentialsException("External system authentication failed");
        }
    }

    @Override
    public boolean supports(Class<?> auth) {
        return auth.equals(UsernamePasswordAuthenticationToken.class);
    }
}

Conséquence

Maintenant nous avons trois moyens de nous authentifier

  • Via les informations enregistrées en base
  • En saisissant memuser/pass pour l'authentification en mémoire
  • En saisissant externaluser/pass pour l'authentification custom

Conclusion

L'authentification n'est pas une partie simple à comprendre. Nous revonnons ici sur les principaux points mis en application.

Des requêtes interceptées

Toutes les requêtes sont interceptées par la Security Filter Chain. Ainsi, si on souhaite accéder à une ressource protégée (e.g. /restricted)

  1. La Security Filter Chain intercepte la requête
  2. Si aucune expception n'est levée, c'est-à-dire que l'utilisateur était bien authentifié
  3. Alors la méthode représentant l'appel (ici restricted()) est invoquée et retourne la ressource.

Différentes authentifications

Nous avons :

  • Créer notre propre système d'authentification afin d'introduire la notion
  • Puis nous avons vu l'authentification Basic qui permet de rentrer dans le détail de la Security Filter Chain
  • Pour au final, créer notre propre filtre afin de réaliser l'authentification JWT

Authentification notion

Authentification Basic

Authentification JWT

Gateway

Nos services sont déployés sur plusieurs serveur et accessibles via le :

  • port :8081 pour bankaccount
  • port :8083 pout registration

Néanmoins il serait intéressant de pour acceder à toutes les APIs en utilisant le même port, par exemple :

  • :8080/account/create au lieu de :8081/account/create
  • :8080/auth/login au lieu de 8083/auth/login

Ainsi la Gateway est un serveur qu'on va déployé et qui va avoir pour rôle de rediriger le traffic vers le bon port. Elle permet d'offrir un accès simplifié.

Configuration

Pom

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>uqac.groupe6</groupId>
		<artifactId>prixbanque</artifactId>
		<version>0.0.1-SNAPSHOT</version>
	</parent>
	<artifactId>gateway</artifactId>

	<dependencies>
		<dependency>
			<groupId>org.springframework.cloud</groupId>
			<artifactId>spring-cloud-starter-gateway</artifactId>
		</dependency>
	</dependencies>
</project>

Application.properties

Dans le fichier src/main/resources/application.properties

spring.main.web-application-type=reactive

Application.yaml

Note
On aurait pu mettre la configuration suivante directement dans application.properties.

Nous configurons les redirections à faire sur notre serveur gateway

server:
  port: 8080

spring:
  cloud:
    gateway:
      routes:
      - id: bankAccountModule
        uri: http://localhost:8081/
        predicates:
        - Path=/account/**
      - id: authentificationModule
        uri: http://localhost:8083/
        predicates:
        - Path=/customer/**

Tester

Nous n'avons rien de plus à écrire, tout se fait au travers des fichiers de configuration.

Maintenant nous pouvons remplacer le port de tous nos tests par :8080.