Authentification par JWT et Spring Security

Ajouter une authentification par JWT

Nous allons maintenant ajouter à notre API une phase d'authentification. Lors du /login le serveur vérifie l'identité et génère un JWT qui, en cas de succès, est renvoyé au client. Ce dernier va utiliser ce JWT dans les requêtes suivantes pour prouver son identité.

  Client                                         API
     |----- /login?username=XX&password=YY  ----->|
     |                                            | authentification
     |                                            | construction du token
     |<------------------- JWT -------------------|


     |                                            |
     |--------- /action + JWT (headers)  -------->|
     |                                            | vérification du token
     |                                            | réalisation
     |<----------------- réponse -----------------|

Mise en oeuvre

Travail à faire : Mise en place.
  • Ce sujet est librement inspiré (et adapté) de ce projet (notamment par le passage à SprintBoot 3).
  • Nous allons récupérer plusieurs classes de configuration et une petite API de test.
  • Placez-vous dans le répertoire de votre projet, téléchargez et décompressez l'archive.
    # la ligne ci-dessous est à adapter à votre ide/WORKSPACE
    cd ~/votre_workspace/votre_projet
    ls -ld src/
    wget http://tinyurl.com/jlmassat2/arch-app/sources-jwt.zip
    unzip sources-jwt.zip
    rm sources-jwt.zip
    
  • Rafraîchissez votre projet. Cette étape a normalement ajouté plusieurs packages qui débutent par myboot.app5.
  • Vérifiez que la compilation ne pose pas de problème.
  • Modifiez la stratégie de sécurité avec la ligne ci-dessous dans le fichier application.properties. Nous avons maintenant trois stratégies (open, simple et usejwt).
    spring.profiles.active=usejwt
    
Travail à faire : Explorez le contrôleur et la configuration de sécurité.
  • myboot.app5.security.JwtProvider : Fabrication et vérification des JWT.
  • myboot.app5.security.JwtWebSecurityConfig : Configuration Spring Security.
  • myboot.app5.security.JwtUserDetails : Description de l'utilisateur authentifié.
  • myboot.app5.security.JwtFilter : Filtre de vérification des requêtes.
  • myboot.app5.security.UserService : Gestion des utilisateurs.
  • myboot.app5.web.UserController : L'API.

Test de l'API

Travail à faire :
  • Testez votre API avec le client ligne de commande curl :
    API="http://localhost:8081/secu-users"
    # cela ne devrait pas fonctionner
    curl "$API/me"
    
  • Conseil : ajoutez des traces dans le JwtProvider et dans JwtFilter afin de bien suivre les étapes.
  • Tentez une authentification et récupérez le jeton :
    curl -X POST "$API/login?username=aaa&password=aaa"
    
  • Utilisez le jeton précédent pour vous authentifier :
    JWT=".... jeton récupéré ...."
    curl -H "Authorization:Bearer $JWT" "$API/me"
    
Travail à faire : Testez les autres end-point :
  • GET /secu-users/username : obtenir des informations sur un utilisateur
  • DELETE /secu-users/username : supprimer un utilisateur (seulement pour les administrateurs)
  • GET /secu-users/refresh : obtenir un nouveau JWT plus récent
Travail à faire : Construisez, avec RestTemplate, un test unitaire d'authentification (l'équivalent des commandes curl).

Déconnexion

Il est actuellement impossible de se déconnecter et nous devons attendre la fin de validité du jeton. Nous allons améliorer ce processus.

Travail à faire :
  • Ajoutez à JwtProvider le stockage des JWT fabriqués (c'est la liste blanche).
  • Ajoutez une entrée GET /secu-users/logout qui permet d'oublier le JWT en question.
  • Enrichissez la phase de validation afin de vérifier que le jeton est connu.
  • Testez que la déconnexion fonctionne.
  • Comment purger le stockage des anciens JWT ? (vous pouvez explorer cette documentation.)

Adapter votre application

Vous devez maintenant enrichir votre application exemple (le gestionnaire de films) avec une phase d'authentification.

Travail à faire : Nous allons enrichir notre application Vue.js :
  • Dans le fichier package.json, ajoutez la dépendance vers Axios ci-dessous. Faites ensuite un npm install dans le répertoire de votre projet pour charger les dépendances.
    Ajout d'Axios
      "dependencies": {
        "axios": "^1.12.2",
        "vue": "^3.5.18"
      },
    
  • Créez deux composants :
    Fichier src/components/Login.vue
    <template>
      <h1>Authentication</h1>
    </template>
    
    Fichier src/components/Logout.vue
    <template>
      <h1>Logout</h1>
    </template>
    
  • Créez la classe Javascript User qui va nous servir à représenter un utilisateur connecté :
    Fichier src/user.js
    import axios from "axios";
    
    export default class User {
        name = "";
        authenticated = false;
        axios = null;
    
        // Préparer une instance Axios avec ou sans token JWT
        initAxios(token = "") {
            let headers = {
                'Content-Type': 'application/json',
            };
            if (token != "") {
                console.log("with token");
                headers['Authorization'] = 'Bearer ' + token;
            }
            this.axios = axios.create({
                baseURL: 'http://localhost:8081/secu-users/',
                timeout: 1000,
                headers: headers,
            });
        }
    
        // Utilisateur standard non-authentifié
        constructor() {
            this.logout();
            this.initAxios();
        }
    
        // Authentification qui renvoie un Promise
        async login(name, password) {
            this.logout();
            let config = {
                params: {
                    username: name,
                    password: password
                }
            };
            return this.axios.get("login", config).then(r => {
                console.log('SUCCESS');
                let token = r.data;
                this.name = name;
                this.authenticated = true;
                this.initAxios(token);
                return (true);
            }).catch(r => {
                console.log('FAILURE');
                return (false);
            });
        }
    
        // Déconnexion
        logout() {
            this.name = "";
            this.authenticated = false;
            this.initAxios();
        }
    
        // Qui suis-je ?
        async whoami() {
            let result = await this.axios.get("me");
            return result.data;
        }
    
        // Une action limitée
        async limited() {
            let result = await this.axios.get("limited");
            return result.data;
        }
    
    }
    
  • Nous pouvons maintenir enrichir le composant App.vue pour prévoir un utilisateur :
    Enrichir la partie template de src/App.vue
    // Enrichir le menu
    <a class="navbar-brand" href="/#login"
        v-if="! user.authenticated" >Login</a>
    <a class="navbar-brand" href="/#logout"
        v-if="user.authenticated" >Logout</a>
    <span class="navbar-item">User {{user.name}}</span>
    
    Enrichir la partie script de src/App.vue
    // Enrichir les imports
    import Login  from "@/components/Login.vue";
    import Logout from "@/components/Logout.vue";
    import {ref, computed, provide} from "vue";
    import User from "@/user.js";
    
    ...
    
    // Définir un utilisateur et l'injecter
    // dans les sous-composants
    const user = ref(new User());
    provide('user', user);
    
    ...
    
    // Enrichir les routes
    const routes = {
      ...
      'login': Login,
      'logout': Logout,
      ...
    };
    
  • Testez le bon fonctionnement de cette nouvelle version.
Note : Dans la classe User, nous stockons le jeton dans la configuration de axios (plus d'information). Nous pourrions également prévoir un stockage dans la classe User.
Travail à faire :
  • Nous allons maintenant définir la phase d'authentification :
    Enrichir la partie template de src/Login.vue
    <h1>Authentication</h1>
    <div v-if="(message != '')" class="alert alert-warning">
      {{ message }}
    </div>
    
    <form id="app" method="post" novalidate="true">
      <div class="mb-3">
        <label>Login :</label>
        <input v-model="name" class="form-control"/>
      </div>
      <div class="mb-3">
        <label>Password :</label>
        <input v-model="password" class="form-control"/>
      </div>
      <div class="mb-3">
        <button @click.prevent="submitLogin()"
          class="ms-2 btn btn-primary">Login</button>
        <button @click.prevent="abort()"
          class="ms-2 btn btn-primary">Abort</button>
      </div>
    </form>
    
    Définir la partie script de src/Login.vue
    <script setup>
    import {ref, inject} from "vue";
    
    // les données de ce composant
    const user = inject("user");
    const name = ref("");
    const password = ref("");
    const message = ref("");
    
    // soumission du formulaire
    async function submitLogin() {
      let ok = await user.value.login(name.value, password.value);
      if (ok) {
        window.location.href = "#page1";
      } else {
        message.value = "bad credential";
      }
    }
    
    // Abandon
    function abort() {
      console.log("abort ");
      window.location.href = "#";
    }
    </script>
    
  • Tester cette nouvelle version.
  • Enrichir le composant Logout.vue afin de réaliser la déconnexion. Vous devrez utiliser la méthode onMounted pour prévoir une action réalisée après initialisation du composant (voir Page1.vue).
  • Prévoir un composant Whoami.vue pour exploiter la méthode whoami de User.
  • Prévoir un composant Limited.vue pour exploiter la méthode limited de User. Cela ne devrait pas fonctionner (consultez la console javascript). Mettez à jour l'annotation @CrossOrigin du contrôleur /limited afin de permettre cette action.
Note : Le mécanisme CORS (Cross-origin resource sharing) permet de contrôler le partage de ressources inter-origines (plus d'information).
Amélioration : A ce stade, un rechargement complet de l'application provoque une déconnexion (l'instance axios est recréée). Pour éviter ce comportement, vous pouvez stocker le jeton JWT dans le sessionStorage (plus d'informations).