From f67b4a9fd6b1e82960c5c68273dd175d261821b1 Mon Sep 17 00:00:00 2001 From: Vincent Guillet Date: Fri, 24 Apr 2026 17:38:37 +0200 Subject: [PATCH] Add user authentication and management modules with Spring Security, JWT, and JPA --- .../app/config/SecurityConfig.java | 99 ++++++++++++ .../app/controller/auth/AuthController.java | 142 ++++++++++++++++++ .../app/controller/user/UserController.java | 54 +++++++ .../app/dto/auth/AuthRequest.java | 6 + .../app/dto/auth/AuthResponse.java | 7 + .../bureauservice/app/dto/user/UserDTO.java | 18 +++ .../app/mapper/user/UserMapper.java | 32 ++++ .../bureauservice/app/model/auth/Token.java | 33 ++++ .../fr/bureauservice/app/model/user/Role.java | 15 ++ .../fr/bureauservice/app/model/user/User.java | 62 ++++++++ .../app/repository/auth/TokenRepository.java | 12 ++ .../app/repository/user/UserRepository.java | 13 ++ .../app/service/auth/AuthService.java | 132 ++++++++++++++++ .../service/auth/JpaUserDetailsService.java | 31 ++++ .../app/service/auth/JwtService.java | 65 ++++++++ .../app/service/auth/TokenService.java | 51 +++++++ .../app/service/user/UserService.java | 61 ++++++++ .../app/util/auth/JwtAuthFilter.java | 74 +++++++++ 18 files changed, 907 insertions(+) create mode 100644 server/app/src/main/java/fr/bureauservice/app/config/SecurityConfig.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/controller/auth/AuthController.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/controller/user/UserController.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/dto/auth/AuthRequest.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/dto/auth/AuthResponse.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/dto/user/UserDTO.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/mapper/user/UserMapper.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/model/auth/Token.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/model/user/Role.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/model/user/User.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/repository/auth/TokenRepository.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/repository/user/UserRepository.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/service/auth/AuthService.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/service/auth/JpaUserDetailsService.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/service/auth/JwtService.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/service/auth/TokenService.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/service/user/UserService.java create mode 100644 server/app/src/main/java/fr/bureauservice/app/util/auth/JwtAuthFilter.java diff --git a/server/app/src/main/java/fr/bureauservice/app/config/SecurityConfig.java b/server/app/src/main/java/fr/bureauservice/app/config/SecurityConfig.java new file mode 100644 index 0000000..248c309 --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/config/SecurityConfig.java @@ -0,0 +1,99 @@ +package fr.bureauservice.app.config; + +import fr.bureauservice.app.service.auth.JpaUserDetailsService; +import fr.bureauservice.app.util.auth.JwtAuthFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtAuthFilter jwtAuthFilter; + private final JpaUserDetailsService userDetailsService; + + public SecurityConfig(JwtAuthFilter jwtAuthFilter, JpaUserDetailsService userDetailsService) { + this.jwtAuthFilter = jwtAuthFilter; + this.userDetailsService = userDetailsService; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) { + http + .cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(authz -> authz + .requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/refresh", "/api/auth/logout").permitAll() + .requestMatchers("/api/auth/me").authenticated() + .requestMatchers("/api/users/**").authenticated() + .requestMatchers("/api/app/**").authenticated() + .anyRequest().permitAll() + ); + + return http.build(); + } + + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOrigins(Arrays.asList( + "http://localhost:4200", + "http://127.0.0.1:4200" + )); + + config.setAllowCredentials(true); + config.setAllowedHeaders(List.of("*")); + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + + config.setExposedHeaders(Arrays.asList("Authorization", "Content-Type", "Set-Cookie")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) { + return config.getAuthenticationManager(); + } +} diff --git a/server/app/src/main/java/fr/bureauservice/app/controller/auth/AuthController.java b/server/app/src/main/java/fr/bureauservice/app/controller/auth/AuthController.java new file mode 100644 index 0000000..786a328 --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/controller/auth/AuthController.java @@ -0,0 +1,142 @@ +package fr.bureauservice.app.controller.auth; + +import fr.bureauservice.app.dto.auth.AuthRequest; +import fr.bureauservice.app.dto.auth.AuthResponse; +import fr.bureauservice.app.dto.user.UserDTO; +import fr.bureauservice.app.mapper.user.UserMapper; +import fr.bureauservice.app.service.auth.AuthService; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @PostMapping("/register") + public ResponseEntity register(@RequestBody UserDTO dto, + HttpServletResponse response) { + return authService.register(UserMapper.fromDto(dto)) + .map(authResponse -> createAuthResponse(authResponse, response)) + .orElse(ResponseEntity.badRequest().build()); + } + + @PostMapping("/login") + public ResponseEntity authenticate(@RequestBody AuthRequest request, + HttpServletResponse response) { + return authService.authenticate(request) + .map(authResponse -> createAuthResponse(authResponse, response)) + .orElse(ResponseEntity.badRequest().build()); + } + + @GetMapping("/logout") + public ResponseEntity logout(HttpServletResponse response) { + ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", "") + .httpOnly(true) + .secure(false) + .path("/") + .maxAge(0) + .sameSite("Lax") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()); + + ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", "") + .httpOnly(true) + .secure(false) + .path("/") + .maxAge(0) + .sameSite("Lax") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + + return ResponseEntity.ok().build(); + } + + @GetMapping("/me") + public ResponseEntity getCurrentUser(@AuthenticationPrincipal UserDetails userDetails) { + if (userDetails == null) { + return ResponseEntity.status(401).build(); + } + + return authService.getCurrentUser(userDetails.getUsername()) + .map(user -> ResponseEntity.ok(UserMapper.toDto(user))) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping("/refresh") + public ResponseEntity refreshPost(HttpServletRequest request, + HttpServletResponse response) { + return handleRefresh(request, response); + } + + @GetMapping("/refresh") + public ResponseEntity refreshGet(HttpServletRequest request, + HttpServletResponse response) { + return handleRefresh(request, response); + } + + private ResponseEntity handleRefresh(HttpServletRequest request, + HttpServletResponse response) { + String refreshToken = null; + + if (request.getCookies() != null) { + refreshToken = Arrays.stream(request.getCookies()) + .filter(c -> "refreshToken".equals(c.getName())) + .findFirst() + .map(Cookie::getValue) + .orElse(null); + } + + if (refreshToken == null) { + return ResponseEntity.noContent().build(); + } + + return authService.refresh(refreshToken) + .map(authResponse -> createAuthResponse(authResponse, response)) + .orElse(ResponseEntity.noContent().build()); + } + + private ResponseEntity createAuthResponse(AuthResponse authResponse, + HttpServletResponse response) { + // Access token en httpOnly cookie + ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", authResponse.accessToken()) + .httpOnly(true) + .secure(false) // true en prod + .path("/") + .maxAge(15 * 60) // 15 minutes + .sameSite("Lax") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()); + + // Refresh token en httpOnly cookie + ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", authResponse.refreshToken()) + .httpOnly(true) + .secure(false) + .path("/") + .maxAge(60 * 60 * 24 * 7) // 7 jours + .sameSite("Lax") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + + // Ne retourner que le username sans les tokens + return ResponseEntity.ok(new AuthResponse( + authResponse.username(), + null, + null + )); + } +} diff --git a/server/app/src/main/java/fr/bureauservice/app/controller/user/UserController.java b/server/app/src/main/java/fr/bureauservice/app/controller/user/UserController.java new file mode 100644 index 0000000..e507118 --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/controller/user/UserController.java @@ -0,0 +1,54 @@ +package fr.bureauservice.app.controller.user; + +import fr.bureauservice.app.model.user.User; +import fr.bureauservice.app.service.user.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/users") +public class UserController { + + private final UserService userService; + + @Autowired + public UserController(UserService userService) { + this.userService = userService; + } + + @GetMapping + public List getAllUsers() { + return userService.getAllUsers(); + } + + @GetMapping("/{id}") + public ResponseEntity getUserById(@PathVariable Long id) { + return userService.getUserById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + public void saveUser(@RequestBody User user) { + userService.saveUser(user); + } + + @PutMapping("/{id}") + public ResponseEntity updateUser(@PathVariable Long id, @RequestBody User user) { + return userService.getUserById(id) + .map(existingUser -> userService.updateUser(user) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build())) + .orElse(ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteUserById(@PathVariable Long id) { + return userService.deleteUserById(id) + .map(user -> ResponseEntity.ok().body(user)) + .orElse(ResponseEntity.notFound().build()); + } +} diff --git a/server/app/src/main/java/fr/bureauservice/app/dto/auth/AuthRequest.java b/server/app/src/main/java/fr/bureauservice/app/dto/auth/AuthRequest.java new file mode 100644 index 0000000..0fbdd60 --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/dto/auth/AuthRequest.java @@ -0,0 +1,6 @@ +package fr.bureauservice.app.dto.auth; + +public record AuthRequest( + String username, + String password +) {} diff --git a/server/app/src/main/java/fr/bureauservice/app/dto/auth/AuthResponse.java b/server/app/src/main/java/fr/bureauservice/app/dto/auth/AuthResponse.java new file mode 100644 index 0000000..6be18f1 --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/dto/auth/AuthResponse.java @@ -0,0 +1,7 @@ +package fr.bureauservice.app.dto.auth; + +public record AuthResponse( + String username, + String accessToken, + String refreshToken +) {} \ No newline at end of file diff --git a/server/app/src/main/java/fr/bureauservice/app/dto/user/UserDTO.java b/server/app/src/main/java/fr/bureauservice/app/dto/user/UserDTO.java new file mode 100644 index 0000000..32711e3 --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/dto/user/UserDTO.java @@ -0,0 +1,18 @@ +package fr.bureauservice.app.dto.user; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class UserDTO { + private Long id; + private String firstName; + private String lastName; + private String username; + private String email; + private String password; + private String role; +} diff --git a/server/app/src/main/java/fr/bureauservice/app/mapper/user/UserMapper.java b/server/app/src/main/java/fr/bureauservice/app/mapper/user/UserMapper.java new file mode 100644 index 0000000..c12da93 --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/mapper/user/UserMapper.java @@ -0,0 +1,32 @@ +package fr.bureauservice.app.mapper.user; + +import fr.bureauservice.app.dto.user.UserDTO; +import fr.bureauservice.app.model.user.Role; +import fr.bureauservice.app.model.user.User; + +public class UserMapper { + + public static User fromDto(UserDTO userDTO) { + User user = new User(); + user.setId(userDTO.getId()); + user.setFirstName(userDTO.getFirstName()); + user.setLastName(userDTO.getLastName()); + user.setUsername(userDTO.getUsername()); + user.setEmail(userDTO.getEmail()); + user.setPassword(userDTO.getPassword()); + user.setRole(Role.valueOf(userDTO.getRole() != null ? userDTO.getRole() : "USER")); + return user; + } + + public static UserDTO toDto(User user) { + UserDTO userDTO = new UserDTO(); + userDTO.setId(user.getId()); + userDTO.setFirstName(user.getFirstName()); + userDTO.setLastName(user.getLastName()); + userDTO.setUsername(user.getUsername()); + userDTO.setEmail(user.getEmail()); + userDTO.setPassword(user.getPassword()); + userDTO.setRole(user.getRole() != null ? user.getRole().getDisplayName() : null); + return userDTO; + } +} diff --git a/server/app/src/main/java/fr/bureauservice/app/model/auth/Token.java b/server/app/src/main/java/fr/bureauservice/app/model/auth/Token.java new file mode 100644 index 0000000..b772eb9 --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/model/auth/Token.java @@ -0,0 +1,33 @@ +package fr.bureauservice.app.model.auth; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import fr.bureauservice.app.model.user.User; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Date; + +@Entity +@Data +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "tokens") +public class Token { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private String value; + + private Date expirationDate; + + @OneToOne + @JoinColumn(name = "user_id", referencedColumnName = "id") + @JsonBackReference + private User user; +} diff --git a/server/app/src/main/java/fr/bureauservice/app/model/user/Role.java b/server/app/src/main/java/fr/bureauservice/app/model/user/Role.java new file mode 100644 index 0000000..687b9fe --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/model/user/Role.java @@ -0,0 +1,15 @@ +package fr.bureauservice.app.model.user; + +import lombok.Getter; + +@Getter +public enum Role { + USER("User"), + ADMIN("Administrator"); + + private final String displayName; + + Role(String displayName) { + this.displayName = displayName; + } +} diff --git a/server/app/src/main/java/fr/bureauservice/app/model/user/User.java b/server/app/src/main/java/fr/bureauservice/app/model/user/User.java new file mode 100644 index 0000000..06a7d39 --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/model/user/User.java @@ -0,0 +1,62 @@ +package fr.bureauservice.app.model.user; + +import com.fasterxml.jackson.annotation.JsonManagedReference; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import fr.bureauservice.app.model.auth.Token; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Entity +@Data +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "users") +public class User implements UserDetails { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + @Column(length = 30, nullable = false) + private String firstName; + + @NotBlank + @Column(length = 30, nullable = false) + private String lastName; + + @Column(length = 50, unique = true) + private String username; + + @Email + @NotBlank + @Column(length = 120, unique = true, nullable = false) + private String email; + + @NotBlank + @Column(length = 120, nullable = false) + private String password; + + @NotNull + @Enumerated(EnumType.STRING) + private Role role = Role.USER; + + @OneToOne(mappedBy = "user") + @JsonManagedReference + private Token token; + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(Role.USER.name())); + } +} diff --git a/server/app/src/main/java/fr/bureauservice/app/repository/auth/TokenRepository.java b/server/app/src/main/java/fr/bureauservice/app/repository/auth/TokenRepository.java new file mode 100644 index 0000000..4aa1445 --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/repository/auth/TokenRepository.java @@ -0,0 +1,12 @@ +package fr.bureauservice.app.repository.auth; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import fr.bureauservice.app.model.auth.Token; + +import java.util.Optional; + +@Repository +public interface TokenRepository extends JpaRepository { + Optional findByValue(String value); +} diff --git a/server/app/src/main/java/fr/bureauservice/app/repository/user/UserRepository.java b/server/app/src/main/java/fr/bureauservice/app/repository/user/UserRepository.java new file mode 100644 index 0000000..31d66ea --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/repository/user/UserRepository.java @@ -0,0 +1,13 @@ +package fr.bureauservice.app.repository.user; + +import fr.bureauservice.app.model.user.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + Optional findByEmail(String email); +} diff --git a/server/app/src/main/java/fr/bureauservice/app/service/auth/AuthService.java b/server/app/src/main/java/fr/bureauservice/app/service/auth/AuthService.java new file mode 100644 index 0000000..82c3e2f --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/service/auth/AuthService.java @@ -0,0 +1,132 @@ +package fr.bureauservice.app.service.auth; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import fr.bureauservice.app.dto.auth.AuthRequest; +import fr.bureauservice.app.dto.auth.AuthResponse; +import fr.bureauservice.app.model.user.User; +import fr.bureauservice.app.model.auth.Token; +import fr.bureauservice.app.repository.user.UserRepository; +import fr.bureauservice.app.repository.auth.TokenRepository; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final AuthenticationManager authenticationManager; + private final JwtService jwtService; + private final JpaUserDetailsService userDetailsService; + private final TokenRepository tokenRepository; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + private final int REFRESH_TOKEN_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 7; // 7 jours + + public Optional register(User user) { + String username = user.getUsername(); + + if (userRepository.findByUsername(username).isPresent()) { + System.err.println("User already exists: " + username); + return Optional.empty(); + } + + user.setPassword(passwordEncoder.encode(user.getPassword())); + userDetailsService.registerNewUser(user); + + return Optional.of(new AuthResponse(username, null, null)); + } + + @Transactional + public Optional authenticate(AuthRequest request) { + // 1. On valide les credentials en tout premier lieu ! + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.username(), request.password()) + ); + + String accessToken = jwtService.generateToken(request.username()); + + UserDetails userDetails = userDetailsService.loadUserByUsername(request.username()); + + User user = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found: " + userDetails.getUsername())); + + Token token = user.getToken() != null + ? tokenRepository.findById(user.getToken().getId()).orElse(null) + : null; + + if (token == null || isRefreshTokenExpired(token)) { + System.out.println("[Authenticate] Refresh token absent ou expiré pour user: " + user.getUsername()); + token = generateNewRefreshToken(user); + } + + return Optional.of(new AuthResponse( + user.getUsername(), + accessToken, + token.getValue() + )); + } + + @Transactional + public Optional getCurrentUser(String username) { + return userRepository.findByUsername(username) + .map(user -> { + if (user.getToken() != null && isRefreshTokenExpired(user.getToken())) { + System.out.println("[AuthService] Refresh token expired for user: " + user.getUsername()); + user.setToken(null); + userRepository.save(user); + } + return user; + }); + } + + @Transactional + public Optional refresh(String refreshTokenValue) { + + if (tokenRepository.findByValue(refreshTokenValue).isPresent()) { + Token savedToken = tokenRepository.findByValue(refreshTokenValue).get(); + + User user = savedToken.getUser(); + + if (isRefreshTokenExpired(savedToken)) { + return Optional.empty(); + } + + String newAccessToken = jwtService.generateToken(user.getUsername()); + return Optional.of(new AuthResponse( + user.getUsername(), + newAccessToken, + refreshTokenValue + )); + } + return Optional.empty(); + } + + private Token generateNewRefreshToken(User user) { + System.out.println("[AuthService] Generating new refresh token for user: " + user.getUsername()); + + Token token = user.getToken(); + if (token == null) { + token = new Token(); + token.setUser(user); + } + + token.setValue(java.util.UUID.randomUUID().toString()); + token.setExpirationDate(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRATION_TIME)); + + tokenRepository.save(token); + user.setToken(token); + return token; + } + + private boolean isRefreshTokenExpired(Token token) { + return token.getExpirationDate().getTime() < System.currentTimeMillis(); + } +} \ No newline at end of file diff --git a/server/app/src/main/java/fr/bureauservice/app/service/auth/JpaUserDetailsService.java b/server/app/src/main/java/fr/bureauservice/app/service/auth/JpaUserDetailsService.java new file mode 100644 index 0000000..00698f1 --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/service/auth/JpaUserDetailsService.java @@ -0,0 +1,31 @@ +package fr.bureauservice.app.service.auth; + +import fr.bureauservice.app.model.user.User; +import fr.bureauservice.app.repository.user.UserRepository; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class JpaUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + public JpaUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + return userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + } + + public void registerNewUser(User user) { + if (userRepository.findByUsername(user.getUsername()).isPresent()) { + throw new IllegalArgumentException("Username already exists: " + user.getUsername()); + } + userRepository.save(user); + } +} diff --git a/server/app/src/main/java/fr/bureauservice/app/service/auth/JwtService.java b/server/app/src/main/java/fr/bureauservice/app/service/auth/JwtService.java new file mode 100644 index 0000000..c7a4996 --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/service/auth/JwtService.java @@ -0,0 +1,65 @@ +package fr.bureauservice.app.service.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import java.security.Key; +import java.util.Base64; +import java.util.Date; +import java.util.function.Function; + +@Service +public class JwtService { + + private static final long EXPIRATION_TIME = 1000 * 60 * 10; // 10min + + private final Key signingKey; + + public JwtService(@Value("${jwt.secret}") String secret) { + this.signingKey = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secret)); + } + + public String generateToken(String username) { + return Jwts.builder() + .setSubject(username) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) + .signWith(signingKey, SignatureAlgorithm.HS256) + .compact(); + } + + public Claims extractAllClaims(String token) { + return Jwts.parser() + .setSigningKey(signingKey) + .build() + .parseClaimsJws(token) + .getBody(); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + private Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + private boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + public boolean isTokenValid(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); + } +} diff --git a/server/app/src/main/java/fr/bureauservice/app/service/auth/TokenService.java b/server/app/src/main/java/fr/bureauservice/app/service/auth/TokenService.java new file mode 100644 index 0000000..678752c --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/service/auth/TokenService.java @@ -0,0 +1,51 @@ +package fr.bureauservice.app.service.auth; + +import fr.bureauservice.app.model.auth.Token; +import fr.bureauservice.app.repository.auth.TokenRepository; +import jakarta.transaction.Transactional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class TokenService { + + private final TokenRepository tokenRepository; + + @Autowired + public TokenService(TokenRepository tokenRepository) { + this.tokenRepository = tokenRepository; + } + + @Transactional + public void saveToken(Token token) { + if (token.getId() == null) { + tokenRepository.save(token); + } + } + + public Optional getTokenById(Long id) { + return tokenRepository.findById(id); + } + + public List getAllTokens() { + return tokenRepository.findAll(); + } + + @Transactional + public Optional updateToken(Token token) { + return tokenRepository.findById(token.getId()).map(existingToken -> { + existingToken.setValue(token.getValue()); + return tokenRepository.save(existingToken); + }); + } + + @Transactional + public Optional deleteTokenById(Long id) { + Optional token = tokenRepository.findById(id); + token.ifPresent(tokenRepository::delete); + return token; + } +} diff --git a/server/app/src/main/java/fr/bureauservice/app/service/user/UserService.java b/server/app/src/main/java/fr/bureauservice/app/service/user/UserService.java new file mode 100644 index 0000000..4989903 --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/service/user/UserService.java @@ -0,0 +1,61 @@ +package fr.bureauservice.app.service.user; + +import fr.bureauservice.app.model.user.User; +import fr.bureauservice.app.repository.user.UserRepository; +import jakarta.transaction.Transactional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class UserService { + + private final UserRepository userRepository; + + @Autowired + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Transactional + public void saveUser(User user) { + if (user.getId() == null) { + userRepository.save(user); + } + } + + public List getAllUsers() { + return userRepository.findAll(); + } + + public Optional getUserById(Long id) { + return userRepository.findById(id); + } + + public Optional getUserByUsername(String username) { + return userRepository.findByUsername(username); + } + + public Optional getUserByEmail(String email) { + return userRepository.findByEmail(email); + } + + @Transactional + public Optional updateUser(User user) { + return userRepository.findById(user.getId()).map(existingUser -> { + existingUser.setEmail(user.getEmail()); + existingUser.setUsername(user.getUsername()); + existingUser.setPassword(user.getPassword()); + return userRepository.save(existingUser); + }); + } + + @Transactional + public Optional deleteUserById(Long id) { + Optional user = userRepository.findById(id); + user.ifPresent(userRepository::delete); + return user; + } +} diff --git a/server/app/src/main/java/fr/bureauservice/app/util/auth/JwtAuthFilter.java b/server/app/src/main/java/fr/bureauservice/app/util/auth/JwtAuthFilter.java new file mode 100644 index 0000000..a7b35b7 --- /dev/null +++ b/server/app/src/main/java/fr/bureauservice/app/util/auth/JwtAuthFilter.java @@ -0,0 +1,74 @@ +package fr.bureauservice.app.util.auth; + +import fr.bureauservice.app.service.auth.JpaUserDetailsService; +import fr.bureauservice.app.service.auth.JwtService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Arrays; + +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + private final JwtService jwtService; + private final JpaUserDetailsService userDetailsService; + + public JwtAuthFilter(JwtService jwtService, JpaUserDetailsService userDetailsService) { + this.jwtService = jwtService; + this.userDetailsService = userDetailsService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + // Récupère le token du cookie httpOnly + String jwt = null; + if (request.getCookies() != null) { + jwt = Arrays.stream(request.getCookies()) + .filter(c -> "accessToken".equals(c.getName())) + .findFirst() + .map(Cookie::getValue) + .orElse(null); + } + + if (jwt == null || SecurityContextHolder.getContext().getAuthentication() != null) { + filterChain.doFilter(request, response); + return; + } + + try { + final String username = jwtService.extractUsername(jwt); + + if (username != null) { + UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); + + if (jwtService.isTokenValid(jwt, userDetails)) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + } catch (Exception e) { + // Token invalide, on continue sans authentification + } + + filterChain.doFilter(request, response); + } +}