Développement d'un Service de Gestion des Utilisateurs avec Spring Boot

Cet article se concentre sur l'implémentation des fonctionnalités backend pour la gestion des utilisateurs au sein d'une application Spring Boot. Nous allons détailler la création des classes de transfert de données (DTOs), un utilitaire de génération d'identifiants uniques, et les points de terminaison REST correspondants.

1. Définition des Modèles et DTOs pour la Gestion des Utilisateurs

Pour interagir avec les données des utilisateurs et gérer les requêtes entrantes, nous avons besoin de plusieurs clases POJO (Plain Old Java Objects) pour le transport et la validation des données.

1.1. Requête de Pagination et de Recherche Utilisateur

Cette classe étend une classe de paramètres de pagination générique et ajoute un critère de recherche spécifique pour le nom d'utilisateur.

import lombok.Data;

// Supposons que PaginationParams est déjà défini ailleurs
// public class PaginationParams {
//     private int page = 1;
//     private int size = 10;
//     // Getters et Setters
// }

@Data
public class UserSearchRequest extends PaginationParams {
    private String username; // Anciennement loginName
}

1.2. Requête de Création ou Mise à Jour Utilisateur

Utilisée pour l'ajout d'un nouvel utilisateur ou la modification d'un utilisateur existant. Elle inclut des validations robustes pour s'assurer de la qualité des données.

import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

@Data
public class UserCreationUpdateRequest {
    private Long id;

    @NotBlank(message = "Le nom d'utilisateur ne peut être vide.")
    private String username; // Anciennement loginName

    @NotBlank(message = "Le nom d'affichage ne peut être vide.")
    private String displayName; // Anciennement name

    @NotBlank(message = "Le mot de passe ne peut être vide.")
    @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z])([a-zA-Z0-9]{6,32})$", 
             message = "Le mot de passe doit contenir au moins un chiffre, une lettre, et avoir une longueur de 6 à 32 caractères.")
    private String password; // Mot de passe brut pour l'entrée
}

1.3. Requête de Réinitialisation de Mot de Passe

Cette classe encapsule les paramètres nécessaires pour réinitialiser le mot de passe d'un utilisateur, avec des validations similaires à celles de la création.

import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

@Data
public class PasswordResetRequest {
    private Long id;

    @NotBlank(message = "Le nouveau mot de passe ne peut être vide.")
    @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z])([a-zA-Z0-9]{6,32})$", 
             message = "Le mot de passe doit contenir au moins un chiffre, une lettre, et avoir une longueur de 6 à 32 caractères.")
    private String newPassword;
}

1.4. DTO de Réponse Utilisateur

Ce DTO est utilisé pour structurer les données d'utilisateur renvoyées au client lors d'une requête de recherche ou de récupération. Pour des raisons de sécurité, le mot de passe est omis.

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserResponseDTO {
    private Long id;
    private String username; // Anciennement loginName
    private String displayName; // Anciennement name
    // Le champ 'password' est délibérément omis pour des raisons de sécurité
}

2. Utilitaire de Génération d'ID Unique : Algorithme Snowflake

L'algorithme Snowflake de Twitter est utilisé ici pour générer des identifiants uniques distribués, sans conflit. Chaque ID est composé d'un horodatage, d'un identifiant de datacenter, d'un identifiant de machine et d'un numéro de séquence.

import org.springframework.stereotype.Component;

/**
 * Implémentation de l'algorithme Snowflake de Twitter pour la génération d'IDs uniques.
 */
@Component
public class SnowflakeIdGenerator {

    /** Horodatage de début (Epoch) : 2021-01-01 00:00:00.000 UTC */
    private final static long EPOCH_START_MILLIS = 1609459200000L;

    /** Nombre de bits alloués à chaque composant de l'ID */
    private final static long BITS_SEQUENCE = 12; // Séquence par milliseconde
    private final static long BITS_ID_MACHINE = 5; // Identifiant de la machine
    private final static long BITS_ID_DATACENTER = 5; // Identifiant du datacenter

    /** Valeurs maximales pour chaque composant */
    private final static long MAX_DATACENTER_ID = -1L ^ (-1L << BITS_ID_DATACENTER);
    private final static long MAX_MACHINE_ID = -1L ^ (-1L << BITS_ID_MACHINE);
    private final static long MAX_SEQUENCE = -1L ^ (-1L << BITS_SEQUENCE);

    /** Décalages de bits pour positionner chaque composant dans l'ID */
    private final static long SHIFT_MACHINE_ID = BITS_SEQUENCE;
    private final static long SHIFT_DATACENTER_ID = BITS_SEQUENCE + BITS_ID_MACHINE;
    private final static long SHIFT_TIMESTAMP = SHIFT_DATACENTER_ID + BITS_ID_DATACENTER;

    private long datacenterIdentifier = 1; // Identifiant du datacenter par défaut
    private long machineIdentifier = 1;    // Identifiant de la machine par défaut
    private long currentSequence = 0L;     // Numéro de séquence
    private long lastTimestamp = -1L;      // Dernier horodatage de génération d'ID

    public SnowflakeIdGenerator() {
    }

    public SnowflakeIdGenerator(long datacenterIdentifier, long machineIdentifier) {
        if (datacenterIdentifier > MAX_DATACENTER_ID || datacenterIdentifier < 0) {
            throw new IllegalArgumentException(
                String.format("L'identifiant du datacenter doit être compris entre 0 et %d", MAX_DATACENTER_ID));
        }
        if (machineIdentifier > MAX_MACHINE_ID || machineIdentifier < 0) {
            throw new IllegalArgumentException(
                String.format("L'identifiant de la machine doit être compris entre 0 et %d", MAX_MACHINE_ID));
        }
        this.datacenterIdentifier = datacenterIdentifier;
        this.machineIdentifier = machineIdentifier;
    }

    /**
     * Génère le prochain identifiant unique.
     *
     * @return Le nouvel ID unique.
     * @throws RuntimeException si l'horloge système recule.
     */
    public synchronized long generateId() {
        long currentMillis = getCurrentTimestampMillis();

        if (currentMillis < lastTimestamp) {
            throw new RuntimeException("L'horloge système a reculé. Incapable de générer un ID.");
        }

        if (currentMillis == lastTimestamp) {
            // Dans la même milliseconde, incrémente la séquence
            currentSequence = (currentSequence + 1) & MAX_SEQUENCE;
            // Si la séquence atteint son maximum, attendre la prochaine milliseconde
            if (currentSequence == 0L) {
                currentMillis = waitForNextMillis(lastTimestamp);
            }
        } else {
            // Nouvelle milliseconde, réinitialise la séquence
            currentSequence = 0L;
        }

        lastTimestamp = currentMillis;

        // Combine les différentes parties pour former l'ID
        return (currentMillis - EPOCH_START_MILLIS) << SHIFT_TIMESTAMP
                | datacenterIdentifier << SHIFT_DATACENTER_ID
                | machineIdentifier << SHIFT_MACHINE_ID
                | currentSequence;
    }

    /**
     * Attend la prochaine milliseconde pour éviter des ID dupliqués si la séquence est épuisée.
     */
    private long waitForNextMillis(long lastTimestamp) {
        long millis = getCurrentTimestampMillis();
        while (millis <= lastTimestamp) {
            millis = getCurrentTimestampMillis();
        }
        return millis;
    }

    /**
     * Retourne l'horodatage actuel en millisecondes.
     */
    private long getCurrentTimestampMillis() {
        return System.currentTimeMillis();
    }
}

3. Implémentation des Points de Terminaison REST (API)

Nous allons maintenant implémenter les contrôleurs et services pour gérer les opérations CRUD (Create, Read, Update, Delete) des utilisateurs. Pour une meilleure sécurité, les mots de passe seront hachés à l'aide d'un PasswordEncoder.

Pour cet exemple, nous allons utiliser une classe ApiResponse générique pour uniformiser les réponses de l'API :

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
    private boolean success;
    private String message;
    private T data;
}

De plus, l'interface UserMapper (supposément générée par MyBatis-Plus) et la classe User (entité de base de données) sont utilisées.

L'interface UserService et son implémentation UserServiceImpl sont essentielles pour la logique métier.

// Exemple de structure pour l'entité utilisateur
// import com.baomidou.mybatisplus.annotation.TableField;
// import com.baomidou.mybatisplus.annotation.TableName;
// import lombok.Data;
//
// @Data
// @TableName("user")
// public class User {
//     private Long id;
//     private String username; // Correspond à login_name dans la BD
//     private String displayName; // Correspond à name dans la BD
//     private String passwordHash; // Correspond à password dans la BD
// }

// Exemple d'interface UserMapper
// import com.baomidou.mybatisplus.core.mapper.BaseMapper;
// import org.apache.ibatis.annotations.Mapper;
//
// @Mapper
// public interface UserMapper extends BaseMapper<User> {
// }

// Interface UserService
public interface UserService {
    ApiResponse<PageVo<UserResponseDTO>> searchUsers(UserSearchRequest request);
    ApiResponse<Void> saveOrUpdateUser(UserCreationUpdateRequest request);
    ApiResponse<Void> deleteUser(Long id);
    ApiResponse<Void> resetUserPassword(PasswordResetRequest request);
}

3.1. Implémentation du Service Utilisateur (UserServiceImpl)

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.beans.BeanUtils;
import org.springframework.security.crypto.password.PasswordEncoder; // Utilisation d'un PasswordEncoder
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class UserServiceImpl implements UserService {

    private final UserMapper userMapper;
    private final SnowflakeIdGenerator idGenerator;
    private final PasswordEncoder passwordEncoder; // Injecter un PasswordEncoder

    public UserServiceImpl(UserMapper userMapper, SnowflakeIdGenerator idGenerator, PasswordEncoder passwordEncoder) {
        this.userMapper = userMapper;
        this.idGenerator = idGenerator;
        this.passwordEncoder = passwordEncoder;
    }

    /**
     * Recherche et pagine les utilisateurs selon les critères fournis.
     */
    @Override
    public ApiResponse<PageVo<UserResponseDTO>> searchUsers(UserSearchRequest request) {
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        if (StringUtils.hasText(request.getUsername())) {
            queryWrapper.eq(User::getUsername, request.getUsername());
        }

        Page<User> userPage = new Page<>(request.getPage(), request.getSize());
        Page<User> resultPage = userMapper.selectPage(userPage, queryWrapper);

        List<UserResponseDTO> dtos = resultPage.getRecords().stream()
                .map(user -> {
                    UserResponseDTO dto = new UserResponseDTO();
                    BeanUtils.copyProperties(user, dto);
                    return dto;
                })
                .collect(Collectors.toList());

        PageVo<UserResponseDTO> responsePage = new PageVo<>();
        responsePage.setList(dtos);
        responsePage.setTotal(resultPage.getTotal());

        return new ApiResponse<>(true, "Recherche d'utilisateurs réussie", responsePage);
    }

    /**
     * Crée un nouvel utilisateur ou met à jour un utilisateur existant.
     */
    @Override
    public ApiResponse<Void> saveOrUpdateUser(UserCreationUpdateRequest request) {
        User userEntity = new User();
        BeanUtils.copyProperties(request, userEntity);

        if (request.getId() == null) { // Création d'un nouvel utilisateur
            User existingUser = findUserByUsername(request.getUsername());
            if (existingUser != null) {
                return new ApiResponse<>(false, "Le nom d'utilisateur existe déjà.", null);
            }
            userEntity.setId(idGenerator.generateId());
            userEntity.setPasswordHash(passwordEncoder.encode(request.getPassword())); // Hachage du mot de passe
            userMapper.insert(userEntity);
            return new ApiResponse<>(true, "Utilisateur créé avec succès.", null);
        } else { // Mise à jour d'un utilisateur existant
            userEntity.setUsername(null); // Ne pas modifier le nom d'utilisateur lors d'une mise à jour
            userEntity.setPasswordHash(null); // Ne pas modifier le mot de passe via cette méthode
            userMapper.updateById(userEntity);
            return new ApiResponse<>(true, "Utilisateur mis à jour avec succès.", null);
        }
    }

    /**
     * Supprime un utilisateur par son identifiant.
     */
    @Override
    public ApiResponse<Void> deleteUser(Long id) {
        int rowsAffected = userMapper.deleteById(id);
        if (rowsAffected > 0) {
            return new ApiResponse<>(true, "Utilisateur supprimé avec succès.", null);
        } else {
            return new ApiResponse<>(false, "Impossible de trouver l'utilisateur à supprimer.", null);
        }
    }

    /**
     * Réinitialise le mot de passe d'un utilisateur.
     */
    @Override
    public ApiResponse<Void> resetUserPassword(PasswordResetRequest request) {
        User userEntity = new User();
        userEntity.setId(request.getId());
        userEntity.setPasswordHash(passwordEncoder.encode(request.getNewPassword())); // Hachage du nouveau mot de passe
        int rowsAffected = userMapper.updateById(userEntity);
        if (rowsAffected > 0) {
            return new ApiResponse<>(true, "Mot de passe réinitialisé avec succès.", null);
        } else {
            return new ApiResponse<>(false, "Impossible de trouver l'utilisateur pour réinitialiser le mot de passe.", null);
        }
    }

    /**
     * Trouve un utilisateur par son nom d'utilisateur.
     */
    private User findUserByUsername(String username) {
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("username", username); // Assurez-vous que le nom de colonne correspond à votre BD
        return userMapper.selectOne(queryWrapper);
    }
}

3.2. Contrôleur REST Utilisateur (UserController)

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;

@RestController
@RequestMapping("/api/users") // Point de terminaison modifié pour /api/users
public class UserController {

    private final UserService userService;

    // Injection de dépendance via le constructeur (préférable à @Autowired sur champ)
    public UserController(UserService userService) {
        this.userService = userService;
    }

    /**
     * Récupère une liste paginée d'utilisateurs.
     * GET /api/users
     * @param request Critères de recherche et de pagination.
     * @return Liste paginée d'utilisateurs.
     */
    @GetMapping
    public ResponseEntity<ApiResponse<PageVo<UserResponseDTO>>> getUsers(@Valid UserSearchRequest request) {
        ApiResponse<PageVo<UserResponseDTO>> response = userService.searchUsers(request);
        return ResponseEntity.ok(response);
    }

    /**
     * Crée un nouvel utilisateur ou met à jour un utilisateur existant.
     * POST /api/users
     * @param request Données de l'utilisateur à sauvegarder.
     * @return Confirmation de l'opération.
     */
    @PostMapping
    public ResponseEntity<ApiResponse<Void>> createUserOrUpdate(@Valid @RequestBody UserCreationUpdateRequest request) {
        ApiResponse<Void> response = userService.saveOrUpdateUser(request);
        return ResponseEntity.status(response.isSuccess() ? 200 : 400).body(response);
    }

    /**
     * Supprime un utilisateur par son ID.
     * DELETE /api/users/{id}
     * @param id L'identifiant de l'utilisateur à supprimer.
     * @return Confirmation de l'opération de suppression.
     */
    @DeleteMapping("/{id}")
    public ResponseEntity<ApiResponse<Void>> deleteUserById(@PathVariable Long id) {
        ApiResponse<Void> response = userService.deleteUser(id);
        return ResponseEntity.status(response.isSuccess() ? 200 : 404).body(response);
    }

    /**
     * Réinitialise le mot de passe d'un utilisateur.
     * POST /api/users/reset-password
     * @param request Données pour la réinitialisation du mot de passe.
     * @return Confirmation de la réinitialisation.
     */
    @PostMapping("/reset-password")
    public ResponseEntity<ApiResponse<Void>> resetPasswordForUser(@Valid @RequestBody PasswordResetRequest request) {
        ApiResponse<Void> response = userService.resetUserPassword(request);
        return ResponseEntity.status(response.isSuccess() ? 200 : 400).body(response);
    }
}

Avec ces composants, le backend de gestion des utilisateurs est prêt à fournir une API robuste pour créer, consulter, modifier et supprimer des comptes utilisateurs, ainsi que pour gérer la réinitialisation de leurs mots de passe de manière sécurisée.

Étiquettes: SpringBoot REST API Gestion Utilisateurs Validation Données MyBatis-Plus

Publié le 9 juin à 20h53