Projet D: Systeme d'Authentification avec Validation Email
Description
Developper une API RESTful complete de gestion d'utilisateurs avec validation par email, gestion des roles et recuperation de mot de passe.
Fonctionnalites Requises
1. Inscription avec Validation Email
POST /api/auth/register - Inscription (envoie email de validation)
GET /api/auth/verify - Valider le compte via token email
POST /api/auth/resend - Renvoyer l'email de validation
2. Connexion et JWT
POST /api/auth/login - Connexion (compte valide requis)
POST /api/auth/refresh - Rafraichir le token
POST /api/auth/logout - Deconnexion
3. Recuperation Mot de Passe
POST /api/auth/forgot-password - Demander reset (envoie email)
POST /api/auth/reset-password - Changer le mot de passe via token
4. Gestion des Utilisateurs
GET /api/users/me - Mon profil
PUT /api/users/me - Modifier mon profil
PUT /api/users/me/password - Changer mon mot de passe
GET /api/admin/users - Liste utilisateurs (ADMIN)
PUT /api/admin/users/{id}/role - Changer le role (ADMIN)
DELETE /api/admin/users/{id} - Supprimer utilisateur (ADMIN)
Schema de Base de Donnees
-- Table users
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
role VARCHAR(20) DEFAULT 'USER',
enabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
-- Table verification_tokens (validation email)
CREATE TABLE verification_tokens (
id BIGSERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
user_id BIGINT REFERENCES users(id),
expiry_date TIMESTAMP NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Table password_reset_tokens
CREATE TABLE password_reset_tokens (
id BIGSERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
user_id BIGINT REFERENCES users(id),
expiry_date TIMESTAMP NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Entites JPA
User Entity
package com.example.auth.model;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
private String firstName;
private String lastName;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Builder.Default
private Role role = Role.USER;
@Builder.Default
private boolean enabled = false; // false jusqu'a validation email
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
public enum Role {
USER, ADMIN
}
}
VerificationToken Entity
package com.example.auth.model;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "verification_tokens")
@Getter @Setter
@NoArgsConstructor
public class VerificationToken {
private static final int EXPIRATION_HOURS = 24;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String token;
@OneToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;
private LocalDateTime expiryDate;
@Builder.Default
private boolean used = false;
private LocalDateTime createdAt;
public VerificationToken(User user) {
this.user = user;
this.token = UUID.randomUUID().toString();
this.expiryDate = LocalDateTime.now().plusHours(EXPIRATION_HOURS);
this.createdAt = LocalDateTime.now();
}
public boolean isExpired() {
return LocalDateTime.now().isAfter(expiryDate);
}
}
PasswordResetToken Entity
package com.example.auth.model;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "password_reset_tokens")
@Getter @Setter
@NoArgsConstructor
public class PasswordResetToken {
private static final int EXPIRATION_HOURS = 1; // 1 heure pour reset
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String token;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;
private LocalDateTime expiryDate;
@Builder.Default
private boolean used = false;
private LocalDateTime createdAt;
public PasswordResetToken(User user) {
this.user = user;
this.token = UUID.randomUUID().toString();
this.expiryDate = LocalDateTime.now().plusHours(EXPIRATION_HOURS);
this.createdAt = LocalDateTime.now();
}
public boolean isExpired() {
return LocalDateTime.now().isAfter(expiryDate);
}
}
DTOs
Request DTOs
// RegisterRequest.java
package com.example.auth.dto.request;
import jakarta.validation.constraints.*;
import lombok.Data;
@Data
public class RegisterRequest {
@NotBlank(message = "Email requis")
@Email(message = "Email invalide")
private String email;
@NotBlank(message = "Mot de passe requis")
@Size(min = 8, message = "Minimum 8 caracteres")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).*$",
message = "Doit contenir majuscule, minuscule et chiffre"
)
private String password;
@NotBlank(message = "Prenom requis")
private String firstName;
@NotBlank(message = "Nom requis")
private String lastName;
}
// LoginRequest.java
@Data
public class LoginRequest {
@NotBlank(message = "Email requis")
@Email
private String email;
@NotBlank(message = "Mot de passe requis")
private String password;
}
// ForgotPasswordRequest.java
@Data
public class ForgotPasswordRequest {
@NotBlank(message = "Email requis")
@Email
private String email;
}
// ResetPasswordRequest.java
@Data
public class ResetPasswordRequest {
@NotBlank(message = "Token requis")
private String token;
@NotBlank(message = "Nouveau mot de passe requis")
@Size(min = 8)
private String newPassword;
}
// ChangeRoleRequest.java
@Data
public class ChangeRoleRequest {
@NotNull
private User.Role role;
}
Response DTOs
// AuthResponse.java
@Data
@Builder
public class AuthResponse {
private String token;
private String type;
private Long expiresIn;
private UserDto user;
}
// UserDto.java
@Data
@Builder
public class UserDto {
private Long id;
private String email;
private String firstName;
private String lastName;
private String role;
private boolean enabled;
private LocalDateTime createdAt;
}
// MessageResponse.java
@Data
@AllArgsConstructor
public class MessageResponse {
private String message;
}
Services
EmailService
package com.example.auth.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class EmailService {
private static final Logger log = LoggerFactory.getLogger(EmailService.class);
private final JavaMailSender mailSender;
@Value("${app.base-url}")
private String baseUrl;
@Value("${spring.mail.username}")
private String fromEmail;
public EmailService(JavaMailSender mailSender) {
this.mailSender = mailSender;
}
@Async
public void sendVerificationEmail(String to, String token) {
String verifyUrl = baseUrl + "/api/auth/verify?token=" + token;
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromEmail);
message.setTo(to);
message.setSubject("Validez votre compte");
message.setText(
"Bienvenue!\n\n" +
"Cliquez sur le lien suivant pour valider votre compte:\n\n" +
verifyUrl + "\n\n" +
"Ce lien expire dans 24 heures.\n\n" +
"Si vous n'avez pas cree de compte, ignorez cet email."
);
try {
mailSender.send(message);
log.info("Email de verification envoye a: {}", to);
} catch (Exception e) {
log.error("Erreur envoi email a {}: {}", to, e.getMessage());
}
}
@Async
public void sendPasswordResetEmail(String to, String token) {
String resetUrl = baseUrl + "/reset-password?token=" + token;
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromEmail);
message.setTo(to);
message.setSubject("Reinitialisation de votre mot de passe");
message.setText(
"Vous avez demande une reinitialisation de mot de passe.\n\n" +
"Cliquez sur le lien suivant pour choisir un nouveau mot de passe:\n\n" +
resetUrl + "\n\n" +
"Ce lien expire dans 1 heure.\n\n" +
"Si vous n'avez pas fait cette demande, ignorez cet email."
);
try {
mailSender.send(message);
log.info("Email de reset envoye a: {}", to);
} catch (Exception e) {
log.error("Erreur envoi email reset a {}: {}", to, e.getMessage());
}
}
}
AuthService
package com.example.auth.service;
import com.example.auth.dto.request.*;
import com.example.auth.dto.response.*;
import com.example.auth.exception.*;
import com.example.auth.model.*;
import com.example.auth.repository.*;
import com.example.auth.security.JwtService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.*;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AuthService {
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
private final UserRepository userRepository;
private final VerificationTokenRepository verificationTokenRepository;
private final PasswordResetTokenRepository passwordResetTokenRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
private final EmailService emailService;
// Constructor injection...
@Transactional
public MessageResponse register(RegisterRequest request) {
// Verifier si email existe
if (userRepository.existsByEmail(request.getEmail())) {
throw new EmailAlreadyExistsException("Email deja utilise");
}
// Creer l'utilisateur (non active)
User user = User.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.firstName(request.getFirstName())
.lastName(request.getLastName())
.role(User.Role.USER)
.enabled(false)
.build();
userRepository.save(user);
// Creer le token de verification
VerificationToken verificationToken = new VerificationToken(user);
verificationTokenRepository.save(verificationToken);
// Envoyer l'email
emailService.sendVerificationEmail(user.getEmail(), verificationToken.getToken());
log.info("Utilisateur enregistre: {}", user.getEmail());
return new MessageResponse(
"Inscription reussie! Verifiez votre email pour activer votre compte."
);
}
@Transactional
public MessageResponse verifyEmail(String token) {
VerificationToken verificationToken = verificationTokenRepository
.findByToken(token)
.orElseThrow(() -> new InvalidTokenException("Token invalide"));
if (verificationToken.isUsed()) {
throw new InvalidTokenException("Token deja utilise");
}
if (verificationToken.isExpired()) {
throw new InvalidTokenException("Token expire");
}
// Activer l'utilisateur
User user = verificationToken.getUser();
user.setEnabled(true);
userRepository.save(user);
// Marquer le token comme utilise
verificationToken.setUsed(true);
verificationTokenRepository.save(verificationToken);
log.info("Compte active: {}", user.getEmail());
return new MessageResponse("Compte active avec succes! Vous pouvez maintenant vous connecter.");
}
@Transactional
public MessageResponse resendVerificationEmail(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UserNotFoundException("Utilisateur non trouve"));
if (user.isEnabled()) {
throw new BadRequestException("Compte deja active");
}
// Invalider les anciens tokens
verificationTokenRepository.deleteByUser(user);
// Creer nouveau token
VerificationToken newToken = new VerificationToken(user);
verificationTokenRepository.save(newToken);
// Envoyer email
emailService.sendVerificationEmail(user.getEmail(), newToken.getToken());
return new MessageResponse("Email de verification renvoye");
}
public AuthResponse login(LoginRequest request) {
// Verifier si le compte est active
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new BadCredentialsException("Email ou mot de passe incorrect"));
if (!user.isEnabled()) {
throw new AccountNotVerifiedException("Compte non verifie. Verifiez votre email.");
}
// Authentifier
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);
// Generer JWT
String token = jwtService.generateToken(user);
log.info("Connexion reussie: {}", user.getEmail());
return AuthResponse.builder()
.token(token)
.type("Bearer")
.expiresIn(jwtService.getExpiration())
.user(mapToUserDto(user))
.build();
}
@Transactional
public MessageResponse forgotPassword(ForgotPasswordRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElse(null);
// Toujours retourner le meme message (securite)
if (user != null && user.isEnabled()) {
// Invalider les anciens tokens
passwordResetTokenRepository.deleteByUser(user);
// Creer nouveau token
PasswordResetToken resetToken = new PasswordResetToken(user);
passwordResetTokenRepository.save(resetToken);
// Envoyer email
emailService.sendPasswordResetEmail(user.getEmail(), resetToken.getToken());
}
return new MessageResponse(
"Si l'email existe, un lien de reinitialisation a ete envoye."
);
}
@Transactional
public MessageResponse resetPassword(ResetPasswordRequest request) {
PasswordResetToken resetToken = passwordResetTokenRepository
.findByToken(request.getToken())
.orElseThrow(() -> new InvalidTokenException("Token invalide"));
if (resetToken.isUsed()) {
throw new InvalidTokenException("Token deja utilise");
}
if (resetToken.isExpired()) {
throw new InvalidTokenException("Token expire");
}
// Changer le mot de passe
User user = resetToken.getUser();
user.setPassword(passwordEncoder.encode(request.getNewPassword()));
userRepository.save(user);
// Marquer le token comme utilise
resetToken.setUsed(true);
passwordResetTokenRepository.save(resetToken);
log.info("Mot de passe reinitialise: {}", user.getEmail());
return new MessageResponse("Mot de passe reinitialise avec succes");
}
private UserDto mapToUserDto(User user) {
return UserDto.builder()
.id(user.getId())
.email(user.getEmail())
.firstName(user.getFirstName())
.lastName(user.getLastName())
.role(user.getRole().name())
.enabled(user.isEnabled())
.createdAt(user.getCreatedAt())
.build();
}
}
UserService (Admin)
package com.example.auth.service;
import com.example.auth.dto.request.ChangeRoleRequest;
import com.example.auth.dto.response.UserDto;
import com.example.auth.model.User;
import com.example.auth.repository.UserRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public Page<UserDto> getAllUsers(Pageable pageable) {
return userRepository.findAll(pageable)
.map(this::mapToDto);
}
@Transactional
public UserDto changeRole(Long userId, ChangeRoleRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("Utilisateur non trouve"));
user.setRole(request.getRole());
User updated = userRepository.save(user);
return mapToDto(updated);
}
@Transactional
public void deleteUser(Long userId) {
if (!userRepository.existsById(userId)) {
throw new RuntimeException("Utilisateur non trouve");
}
userRepository.deleteById(userId);
}
private UserDto mapToDto(User user) {
return UserDto.builder()
.id(user.getId())
.email(user.getEmail())
.firstName(user.getFirstName())
.lastName(user.getLastName())
.role(user.getRole().name())
.enabled(user.isEnabled())
.createdAt(user.getCreatedAt())
.build();
}
}
Controllers
AuthController
package com.example.auth.controller;
import com.example.auth.dto.request.*;
import com.example.auth.dto.response.*;
import com.example.auth.service.AuthService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/register")
public ResponseEntity<MessageResponse> register(
@Valid @RequestBody RegisterRequest request) {
return ResponseEntity.ok(authService.register(request));
}
@GetMapping("/verify")
public ResponseEntity<MessageResponse> verifyEmail(@RequestParam String token) {
return ResponseEntity.ok(authService.verifyEmail(token));
}
@PostMapping("/resend")
public ResponseEntity<MessageResponse> resendVerification(
@RequestParam String email) {
return ResponseEntity.ok(authService.resendVerificationEmail(email));
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(
@Valid @RequestBody LoginRequest request) {
return ResponseEntity.ok(authService.login(request));
}
@PostMapping("/forgot-password")
public ResponseEntity<MessageResponse> forgotPassword(
@Valid @RequestBody ForgotPasswordRequest request) {
return ResponseEntity.ok(authService.forgotPassword(request));
}
@PostMapping("/reset-password")
public ResponseEntity<MessageResponse> resetPassword(
@Valid @RequestBody ResetPasswordRequest request) {
return ResponseEntity.ok(authService.resetPassword(request));
}
}
AdminController
package com.example.auth.controller;
import com.example.auth.dto.request.ChangeRoleRequest;
import com.example.auth.dto.response.UserDto;
import com.example.auth.service.UserService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/admin")
@PreAuthorize("hasRole('ADMIN')")
public class AdminController {
private final UserService userService;
public AdminController(UserService userService) {
this.userService = userService;
}
@GetMapping("/users")
public ResponseEntity<Page<UserDto>> getAllUsers(Pageable pageable) {
return ResponseEntity.ok(userService.getAllUsers(pageable));
}
@PutMapping("/users/{id}/role")
public ResponseEntity<UserDto> changeUserRole(
@PathVariable Long id,
@Valid @RequestBody ChangeRoleRequest request) {
return ResponseEntity.ok(userService.changeRole(id, request));
}
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
}
Configuration Email
application.yml
spring:
mail:
host: smtp.gmail.com
port: 587
username: ${MAIL_USERNAME}
password: ${MAIL_PASSWORD}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
app:
base-url: http://localhost:8080
jwt:
secret: ${JWT_SECRET}
expiration: 86400000 # 24 heures
Pour Gmail
- Activer "2-Step Verification"
- Creer un "App Password" dans les parametres Google
- Utiliser ce password dans
MAIL_PASSWORD
Alternative: MailHog (Dev)
# Demarrer MailHog (intercepte les emails)
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
# Configuration application-dev.yml
spring:
mail:
host: localhost
port: 1025
# Pas besoin de username/password
# Voir les emails: http://localhost:8025
Flux Complet
1. Inscription
POST /api/auth/register
{
"email": "john@example.com",
"password": "Password123",
"firstName": "John",
"lastName": "Doe"
}
Response: { "message": "Inscription reussie! Verifiez votre email..." }
2. Validation Email
GET /api/auth/verify?token=abc123-def456-...
Response: { "message": "Compte active avec succes!" }
3. Connexion
POST /api/auth/login
{
"email": "john@example.com",
"password": "Password123"
}
Response: {
"token": "eyJhbGciOiJIUzI1NiIs...",
"type": "Bearer",
"expiresIn": 86400000,
"user": {
"id": 1,
"email": "john@example.com",
"role": "USER"
}
}
4. Mot de Passe Oublie
POST /api/auth/forgot-password
{
"email": "john@example.com"
}
Response: { "message": "Si l'email existe, un lien..." }
5. Reset Mot de Passe
POST /api/auth/reset-password
{
"token": "xyz789-...",
"newPassword": "NewPassword456"
}
Response: { "message": "Mot de passe reinitialise avec succes" }
6. Changer Role (Admin)
PUT /api/admin/users/1/role
Authorization: Bearer <admin-token>
{
"role": "ADMIN"
}
Response: {
"id": 1,
"email": "john@example.com",
"role": "ADMIN"
}
Tests Requis
| Test | Description |
|---|---|
| Inscription | Email envoye, utilisateur non active |
| Validation | Token valide active le compte |
| Token expire | Erreur si token expire |
| Login | Echec si non verifie |
| Forgot password | Email envoye |
| Reset password | Mot de passe change |
| Change role | Admin peut changer role |
Bonus
| Fonctionnalite | Points |
|---|---|
| Rate limiting sur auth | +3 |
| Historique connexions | +3 |
| Blocage apres X tentatives | +5 |
| 2FA (TOTP) | +10 |
| OAuth2 (Google) | +10 |