CodeGym /Cours /Spring Security /Modèle UserAccount

Modèle UserAccount

Spring Security
Niveau 14, Leçon 1
Disponible

1. UserAccount comme noyau de sécurité

Pour simplifier à l’extrême, UserAccount n’est pas « l’utilisateur en tant que personne », mais « l’utilisateur en tant que laissez-passer pour le système ». Lorsqu’une requête arrive dans l’application, Spring Security doit répondre à plusieurs questions très rapidement et sans ambiguïté : comment s’appelle cet utilisateur dans le système, peut-on même le laisser passer, et quels sont ses droits de base. Tout le reste — « bio », avatar, couleur préférée des boutons — n’a rien à voir avec ces décisions, même si nous aimerions tout stocker dans une seule belle classe User.

Il est utile de garder à l’esprit qu’auparavant nous utilisions des utilisateurs in‑memory comme banc d’essai pédagogique. L’utilisateur était créé via un builder, les rôles étaient à côté, le mot de passe était encodé, et tout fonctionnait. Mais du point de vue du produit, cette approche signifie que les comptes « vivent » à l’intérieur de l’application, comme des meubles cloués au sol : on redémarre le service — et les meubles sont de nouveau exactement aux mêmes clous. En réalité, un compte doit vivre dans les données, survivre aux redémarrages, pouvoir être modifié par l’administrateur, bloqué, désactivé, sans nécessiter de modification de la configuration Java.

Pour ne pas se perdre, on peut imaginer UserAccount comme une réponse minimale à la question : de quoi Spring Security a-t-il besoin pour prendre une décision. Et là, le contrat déjà connu UserDetails nous aide : il comporte getUsername(), getPassword(), ainsi que des indicateurs comme isEnabled() et isAccountNonLocked(). Notre objectif est de concevoir les champs dans la BDD de sorte que ces indicateurs ne soient pas une « décoration », mais de véritables acteurs du flux de connexion.

Ci-dessous, un schéma très simplifié montrant comment UserAccount participe à la décision d’authentification (sans entrer dans les détails du chargement depuis la BDD, car nous concevons le modèle pour l’instant) :

flowchart TD
    A["Connexion : username + password"] --> B["Trouver le compte par username"]
    B --> C["Vérifier passwordHash via PasswordEncoder"]
    C --> D{"enabled et accountNonLocked"}
    D -->|oui| E["Authentification réussie : rôles disponibles pour l'autorisation"]
    D -->|non| F["Connexion refusée : compte désactivé ou verrouillé"]

2. Champs de UserAccount : le minimum

Lors de la conception d’une entité, surtout dans les premiers pas, il y a toujours la tentation « d’ajouter encore un peu ». Aujourd’hui on ajoute displayName (pourquoi pas), demain timezone, après‑demain favoriteQuoteFromLinuxManual, et voilà que l’entité de sécurité ressemble à une valise où l’on fourre tout et n’importe quoi. Cela nuit à la lisibilité et dégrade la sécurité : plus il y a de champs dans UserAccount, plus le risque est grand qu’un jour on renvoie « un truc en trop » dans la réponse. Nous avons donc besoin d’un ensemble de champs qui répondent précisément aux questions de sécurité et aux scénarios administratifs de base.

Voici une « carte des champs » pratique que l’on peut considérer comme une check‑list. J’indique volontairement « pourquoi ce champ est nécessaire », car les champs sans raison se transforment vite en cimetière d’idées.

Champ Type Pourquoi est‑il nécessaire pour la couche sécurité
id Long Identifiant technique en BDD, pratique pour les relations (profil, contenu, fichiers).
username String Identifiant principal (ce qui permet de chercher le compte lors de la connexion).
email String Contact et invariant d’unicité séparé ; à ne pas mélanger avec username.
passwordHash String On stocke le hash, pas le mot de passe brut. C’est le « mot de passe » aux yeux de Spring Security.
enabled boolean Le compte peut‑il passer une connexion normale (accès au système) ?
accountNonLocked boolean Le compte n’est‑il pas verrouillé (souvent réaction à des infractions/actions admin) ?
roles Set<Role> Droits à gros grain : USER, EDITOR, ADMIN.
createdAt Instant Élément pragmatique : date de création du compte (audit, débogage, admin).

Le point le plus important dans ce tableau est le nom passwordHash. Ce n’est ni du cosmétique ni « comme on l’appelle, on navigue ». Si le champ s’appelle password, le cerveau (surtout celui d’un débutant après trois nuits blanches) pourrait un jour décider qu’il est normal d’y mettre un mot de passe brut. En revanche, si le champ s’appelle passwordHash, il rappelle à chaque fois : « mon ami, ici ce n’est pas un mot de passe, c’est un hash, calme‑toi ».

Voici un squelette Java minimal (sans annotations JPA pour l’instant, juste pour voir la forme) :

import java.util.Set;

public class UserAccount {
    // Identifiant technique de l’enregistrement en BDD (pas un “login métier”).
    Long id;

    // Ce qui permet habituellement à Spring Security de trouver l’utilisateur lors de la connexion (principal).
    String username;

    // Contact et unicité séparée : ne pas mélanger avec username.
    String email;

    // IMPORTANT : c’est bien le hash/encoded‑password, pas une chaîne brute.
    String passwordHash;

    // Compte “activé” (l’admin peut le désactiver sans supprimer l’enregistrement).
    boolean enabled;

    // Compte non verrouillé (axe séparé des rôles).
    boolean accountNonLocked;

    // Ensemble de rôles pour l’autorisation après une authentification réussie.
    Set<Role> roles;
}

Oui, cela a l’air très « simple ». Et c’est bien. Dans un bon modèle de sécurité, la simplicité n’est pas un signe de pauvreté, mais un signe de maîtrise.

3. passwordHash : stockage du mot de passe

Chez les débutants, il y a un conflit intérieur permanent entre « je veux que ça marche » et « je veux que ce soit sécurisé ». Les mots de passe sont l’endroit où ce conflit est le plus souvent perdu en une demi‑heure, surtout quand la deadline vous chuchote déjà à l’oreille. Mais dans notre cours, nous sommes déjà tombés d’accord : le mot de passe vit uniquement sous forme encodée, et PasswordEncoder est le seul canal par lequel le mot de passe devient quelque chose de stockable. Maintenant, nous transférons simplement cette discipline du monde in‑memory vers le monde backé par une BDD.

Il est important de comprendre une chose simple : passwordHash n’est pas une « chaîne secrète dans la base » que personne ne verra. D’une part, les bases de données « fuient » parfois (et cela arrive plus souvent qu’on ne le pense). D’autre part, les logs « fuient » aussi (et c’est tout aussi douloureux). Enfin, il arrive que quelqu’un dépose un dump de base dans le chat de l’équipe « pour aider » (c’est un folklore à part entière). Le modèle doit donc être conçu de sorte que, même dans le pire des scénarios, le mot de passe brut ne se retrouve pas dans les données.

Et une autre raison pratique : Spring Security, dans le modèle username/password, s’attend à ce que l’utilisateur ait un « mot de passe » (formellement getPassword() de UserDetails). Mais ce n’est pas un « mot de passe », c’est un mot de passe encodé. Donc passwordHash est ce qui participera à la vérification via PasswordEncoder.matches(...).

Un petit exemple qui montre la bonne idée : le mot de passe en entrée est brut, mais seul le hash atterrit dans UserAccount.

import java.util.Set;
import org.springframework.security.crypto.password.PasswordEncoder;

public UserAccount createDemoUser(String username, String rawPassword, PasswordEncoder encoder) {
    // rawPassword ne vit qu’ici : on le transforme en hash et on ne le traîne pas ensuite dans le système.
    String hash = encoder.encode(rawPassword);

    // Dans le modèle métier, on ne conserve que le résultat de encode(...), pas le mot de passe d’origine.
    return UserAccount.active(username, username + "@example.com", hash, Set.of(Role.USER));
}

Remarquez le point psychologiquement important : rawPassword ne vit que le temps nécessaire pour être haché. Il ne doit pas voyager dans l’application comme un touriste sans visa.

4. États : enabled et accountNonLocked

Un bug très typique dans les projets de débutants : « si l’utilisateur a le rôle ADMIN, il doit avoir accès à tout ». Cela semble logique… jusqu’à ce qu’apparaisse la tâche « bloquer le compte de l’admin parce que son mot de passe a fui ». Et là, on se rend compte que le rôle concerne les droits, tandis que l’état du compte concerne l’autorisation de connexion normale. Ce sont deux axes différents, et il faut les garder séparés, sinon vous ne pourrez pas bloquer le compte, ou bien vous commencerez à bloquer les droits de manière bizarre, comme « enlever le rôle ADMIN mais le laisser en USER ».

Le booléen enabled répond à la question : « ce compte est‑il actif dans le système ? ». Parfois, cela ressemble à un « interrupteur » : l’administrateur l’éteint, l’utilisateur ne peut plus se connecter, mais l’enregistrement reste en base. C’est pratique pour respecter la logique métier et l’audit : le compte a existé, mais il est désactivé.

Le booléen accountNonLocked répond à un scénario plus « répressif » : le compte peut être actif, mais temporairement verrouillé. Le plus souvent, c’est une mesure administrative ou une réaction à un comportement suspect. Par essence, ce n’est ni un « rôle » ni un « droit », c’est un état de sécurité.

Pour rendre l’idée vraiment concrète, il est utile d’ajouter au modèle une petite méthode qui exprime la règle de connexion comme une phrase lisible. Même si cette méthode n’est pas appelée directement par Spring Security (qui a ses propres mécanismes), elle vous aide à penser correctement :

public boolean canUseNormalLogin() {
    // La “connexion normale” n’est possible que si le compte est à la fois activé et non verrouillé.
    // Les rôles n’interviennent pas ici : ils sont vérifiés après une authentification réussie.
    return enabled && accountNonLocked;
}

Et là apparaît un excellent effet « anti‑magique » : vous voyez de vos propres yeux que même un ADMIN n’est pas obligé de se connecter si enabled == false ou si le compte est verrouillé. Les rôles n’entrent pas dans cette méthode, car les rôles concernent l’accès après une authentification réussie.

Si l’on fait une analogie, les roles sont « quelles portes tu peux ouvrir à l’intérieur du bâtiment », tandis que enabled/accountNonLocked sont « est‑ce qu’on te laisse entrer dans le bâtiment ». Et oui, parfois le vigile ne laisse même pas entrer le directeur. Surtout si le directeur a oublié son badge et essaie de prouver qu’il est le directeur en disant « mais c’est moi qui ai tout construit ici ».

5. Invariants de UserAccount

Le mot « invariant » sonne comme une incantation de fantasy mathématique, mais en pratique c’est simplement une règle du type : « si cette règle est violée, le système est cassé ou devient non sécurisé ». Pour les comptes, les invariants sont particulièrement importants car le flux d’authentification repose sur eux. Si vous avez deux utilisateurs avec le même username, ou si passwordHash devient soudainement null, alors commencent les aventures du genre « pourquoi on a tantôt du 500, tantôt du 401, tantôt des fantômes dans les logs ».

Il y a deux couches d’invariants : ceux que vous vérifiez dans le code (validation au niveau service) et ceux que vous fixez dans la BDD (contraintes). Au niveau des fondamentaux, il est important au moins de comprendre que la BDD doit aider, et non pas être un « stockage de chaînes sans opinion ». Sinon, tôt ou tard, vous rencontrerez une situation où l’application a vérifié l’« unicité » dans le code, mais une requête parallèle a réussi à insérer un doublon – et tout s’effondre.

Quels invariants pour UserAccount semblent les plus « incassables » : username doit être unique et non null. L’email aussi doit être unique et non null (si vous avez décidé que l’email est obligatoire). passwordHash ne doit pas être null, car sinon il est impossible de vérifier le mot de passe. De plus, enabled et accountNonLocked doivent avoir des valeurs par défaut claires. Dans un projet pédagogique, il est généralement commode de démarrer avec enabled = true et accountNonLocked = true, pour que le compte puisse se connecter par défaut, et que le blocage/désactivation soient des événements explicites.

Si on montre cela très brièvement au niveau JPA (sans « plonger dans l’ORM »), l’idée ressemble à ceci :

import jakarta.persistence.*;

@Entity
@Table(name = "user_accounts")
public class UserAccount {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; // PK technique : nécessaire pour les relations et les références stables au compte.

    @Column(nullable = false, unique = true)
    private String username; // Invariant pour le flux de connexion : username obligatoire et unique.
}

Ce n’est pas toute la classe, mais juste l’idée : « username obligatoire et unique ». Une telle bribe d’annotations n’est pas de la « magie JPA », mais une manière de fixer l’invariant là où il doit être : dans la structure de données.

6. Rôles : Set<Role> et les limites

Lorsque nous stockons les rôles, on a envie de faire « vite » : mettre une chaîne "ADMIN" dans un champ role et s’en aller. Mais la réalité est un peu plus nuancée : un compte peut avoir plusieurs rôles (par exemple, USER + EDITOR), et ce n’est pas une « histoire complexe d’entreprise », c’est tout à fait normal, même dans un projet pédagogique. C’est pourquoi, dans le modèle, les rôles sont un ensemble, pas une seule chaîne.

En même temps, les rôles ne doivent pas « se substituer » aux états du compte. Si le compte est verrouillé, il ne doit pas pouvoir se connecter, quels que soient ses rôles. Si le compte est désactivé, il ne doit pas non plus se connecter. Inversement, si le compte est actif, les rôles déterminent ce qu’il peut faire à l’intérieur du système. Ainsi, on évite le chaos où le blocage est implémenté via « suppression du rôle » et où soudainement l’utilisateur se connecte en tant que USER, alors qu’en principe il ne devrait pas se connecter du tout.

Au niveau du modèle Java, cela semble extrêmement simple :

import java.util.Set;

public class UserAccount {
    private String username; // Identifiant principal : c’est par lui qu’on trouve le compte lors de la connexion.
    private Set<Role> roles; // Les rôles sont un ensemble : sans ordre et sans doublons.
}

Et oui, Set ici n’est pas un caprice. Il empêche « deux rôles identiques dans l’ensemble » et reflète bien le sens : les rôles sont un ensemble, pas une liste avec un ordre.

Le principe pratique le plus important : le rôle est un langage à gros grain, il ne remplace ni l’état du compte, ni la propriété, ni les permissions fines. Mais dans le cadre de l’étape d’aujourd’hui, ce qui importe est autre chose : les rôles doivent exister en tant que partie du compte, car sinon l’interface d’administration (/api/admin/**) gérera « quelque chose qui n’existe pas ».

7. Marquage JPA minimal de UserAccount

Quand une BDD apparaît, certains développeurs voient s’éveiller l’architecte intérieur avec l’envie de construire l’Empire Romain : tables séparées, dictionnaires, migrations, relations complexes. C’est normal… mais aujourd’hui nous faisons un autre genre : un modèle suffisant minimal pour que la sécurité puisse s’appuyer sur de vrais utilisateurs. Les annotations JPA ne sont donc pas là pour la beauté ou pour un « enterprise correct », mais pour fixer : c’est une entité, elle a une table, et les champs portent des invariants.

La chose la plus utile que l’on puisse faire à ce moment‑là est de marquer soigneusement les champs clés et de leur donner des noms de colonnes compréhensibles. Il est particulièrement important de nommer passwordHash pour qu’il ne devienne pas « password » dans le schéma de la BDD et ne provoque pas d’erreurs.

Exemple de marquage minimal pour quelques champs (encore une fois : court, concret) :

import jakarta.persistence.*;

@Entity
@Table(name = "user_accounts")
public class UserAccount {
    @Column(name = "password_hash", nullable = false, length = 200)
    private String passwordHash; // On stocke uniquement le hash/encoded‑password (pas le brut).

    @Column(nullable = false)
    private boolean enabled = true; // Valeur par défaut : compte activé, la désactivation est une action explicite.
}

Deux commentaires importants. Premièrement, length = 200 n’est pas un « nombre magique », c’est juste une marge de sécurité pour les hash (bcrypt, par exemple, tient généralement dedans, mais une marge ne fait pas de mal). Deuxièmement, les valeurs par défaut au niveau du champ aident à maintenir le modèle dans un état cohérent, même avant d’écrire des services complets de gestion des comptes.

Et encore un point directement lié à la sécurité, bien qu’il semble « juste architectural » : ne renvoyez pas UserAccount à l’extérieur comme réponse API. L’entité est un modèle de stockage, pas un modèle de contrat public. Pour l’API, il est préférable de construire une représentation sécurisée qui ne contient pas du tout passwordHash.

Par exemple, un DTO minimal sous forme de record :

// DTO pour l’API : projection sécurisée du compte sans passwordHash.
public record UserAccountView(
        Long id,
        String username,
        String email,
        boolean enabled,
        boolean accountNonLocked
) {}

Qu’il soit ennuyeux – c’est un compliment. Les DTO ennuyeux sont souvent plus sûrs que les « beaux objets universels ».

8. Erreurs typiques lors du travail avec UserAccount

Erreur n°1 : le champ password et le mot de passe brut à l’intérieur.
Cela n’arrive pas parce que le développeur est méchant ou bête, mais parce que le cerveau économise de l’énergie. Si le champ s’appelle password, alors quelque part dans les tests ou les données d’amorçage, la main écrira automatiquement "qwerty". Le nom passwordHash rappelle à chaque fois qu’il faut stocker le résultat de PasswordEncoder, et non la chaîne d’origine.

Erreur n°2 : mélanger les rôles et les états du compte en un seul « statut ».
Parfois, on crée un champ status = "ADMIN_ACTIVE" ou status = "LOCKED_USER", et toute la logique se transforme en analyse de chaînes et en switch sur des « valeurs magiques ». À ce niveau, il est bien plus sain de garder les rôles séparément (roles) et les états séparément (enabled, accountNonLocked). Ainsi, bloquer un compte ne nécessite pas de « repenser » les rôles, et les rôles ne cassent pas la possibilité de connexion.

Erreur n°3 : stocker les rôles dans une seule chaîne avec des virgules.
"USER,ADMIN" semble être une solution rapide, jusqu’à ce que vous rencontriez des espaces, des casses, des doublons et des "USER,ADMIN," inattendus. Puis apparaît un analyseur, puis un deuxième, puis un troisième… et soudain vous avez écrit un mini‑framework « CSV Roles Security Edition ». Set<Role> résout 90 % de cette douleur avant même de commencer.

Erreur n°4 : absence d’unicité de username et email au niveau de la BDD.
Vérifier « qu’il n’y a pas déjà ce username » dans le code est utile, mais sans contrainte d’unicité en BDD, vous obtiendrez un jour deux login identiques à cause de requêtes concurrentes. Pour la sécurité, c’est particulièrement gênant, car « qui s’est connecté exactement » devient une question non pas philosophique, mais très pratique. L’unicité n’est pas une option, c’est un fondement.

Erreur n°5 : renvoyer UserAccount au format JSON « pour simplifier ».
À un moment donné, on a envie d’écrire return userAccount; dans le contrôleur et de « refactoriser joliment plus tard ». L’expérience montre que « plus tard » peut arriver six mois après, une fois que les données ont fui dans les logs, dans les collections Postman de l’équipe et dans les habitudes. Il est plus correct de séparer tout de suite : l’entité pour le stockage, le DTO pour l’API. Et même si le DTO semble être « de la bureaucratie inutile », c’est une bureaucratie qui un jour vous épargnera beaucoup de nerfs.

1
Mission
Spring Security, niveau 14, leçon 1
Bloqué
Modele minimal de compte
Modele minimal de compte
1
Mission
Spring Security, niveau 14, leçon 1
Bloqué
Schéma SQL avec invariants de compte
Schéma SQL avec invariants de compte
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION