From 9ae36deb6a1851627d3d159b0d86168038705e99 Mon Sep 17 00:00:00 2001 From: Oleksii Morenets Date: Wed, 3 Jul 2024 00:17:59 +0200 Subject: [PATCH 1/8] fix formatting and minor changes --- .../alibou/security/SecurityApplication.java | 52 +++--- .../auditing/ApplicationAuditAware.java | 3 +- .../auth/AuthenticationController.java | 40 ++--- .../security/auth/AuthenticationRequest.java | 4 +- .../security/auth/AuthenticationResponse.java | 8 +- .../security/auth/AuthenticationService.java | 168 +++++++++--------- .../alibou/security/auth/RegisterRequest.java | 10 +- .../java/com/alibou/security/book/Book.java | 11 +- .../alibou/security/book/BookController.java | 4 +- .../security/config/ApplicationConfig.java | 61 ++++--- .../config/JwtAuthenticationFilter.java | 78 ++++---- .../alibou/security/config/JwtService.java | 142 +++++++-------- .../alibou/security/config/LogoutService.java | 38 ++-- .../config/SecurityConfiguration.java | 12 +- .../alibou/security/demo/AdminController.java | 3 + .../alibou/security/demo/DemoController.java | 9 +- .../security/demo/ManagementController.java | 4 +- .../java/com/alibou/security/token/Token.java | 24 +-- .../security/token/TokenRepository.java | 15 +- .../com/alibou/security/token/TokenType.java | 2 +- .../com/alibou/security/user/Permission.java | 6 +- .../java/com/alibou/security/user/Role.java | 68 ++++--- .../java/com/alibou/security/user/User.java | 80 +++++---- .../alibou/security/user/UserController.java | 5 +- .../alibou/security/user/UserRepository.java | 4 +- .../com/alibou/security/user/UserService.java | 2 + src/main/resources/application.yml | 4 +- .../security/SecurityApplicationTests.java | 6 +- 28 files changed, 415 insertions(+), 448 deletions(-) diff --git a/src/main/java/com/alibou/security/SecurityApplication.java b/src/main/java/com/alibou/security/SecurityApplication.java index 1448cff4..7293a0b1 100644 --- a/src/main/java/com/alibou/security/SecurityApplication.java +++ b/src/main/java/com/alibou/security/SecurityApplication.java @@ -2,7 +2,6 @@ import com.alibou.security.auth.AuthenticationService; import com.alibou.security.auth.RegisterRequest; -import com.alibou.security.user.Role; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -16,33 +15,30 @@ @EnableJpaAuditing(auditorAwareRef = "auditorAware") public class SecurityApplication { - public static void main(String[] args) { - SpringApplication.run(SecurityApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(SecurityApplication.class, args); + } - @Bean - public CommandLineRunner commandLineRunner( - AuthenticationService service - ) { - return args -> { - var admin = RegisterRequest.builder() - .firstname("Admin") - .lastname("Admin") - .email("admin@mail.com") - .password("password") - .role(ADMIN) - .build(); - System.out.println("Admin token: " + service.register(admin).getAccessToken()); + @Bean + public CommandLineRunner commandLineRunner(AuthenticationService service) { + return args -> { + var admin = RegisterRequest.builder() + .firstname("Admin") + .lastname("Admin") + .email("admin@mail.com") + .password("password") + .role(ADMIN) + .build(); + System.out.println("Admin token: " + service.register(admin).getAccessToken()); - var manager = RegisterRequest.builder() - .firstname("Admin") - .lastname("Admin") - .email("manager@mail.com") - .password("password") - .role(MANAGER) - .build(); - System.out.println("Manager token: " + service.register(manager).getAccessToken()); - - }; - } + var manager = RegisterRequest.builder() + .firstname("Admin") + .lastname("Admin") + .email("manager@mail.com") + .password("password") + .role(MANAGER) + .build(); + System.out.println("Manager token: " + service.register(manager).getAccessToken()); + }; + } } diff --git a/src/main/java/com/alibou/security/auditing/ApplicationAuditAware.java b/src/main/java/com/alibou/security/auditing/ApplicationAuditAware.java index 3f8172f6..5a5a1792 100644 --- a/src/main/java/com/alibou/security/auditing/ApplicationAuditAware.java +++ b/src/main/java/com/alibou/security/auditing/ApplicationAuditAware.java @@ -9,6 +9,7 @@ import java.util.Optional; public class ApplicationAuditAware implements AuditorAware { + @Override public Optional getCurrentAuditor() { Authentication authentication = @@ -16,7 +17,7 @@ public Optional getCurrentAuditor() { .getContext() .getAuthentication(); if (authentication == null || - !authentication.isAuthenticated() || + !authentication.isAuthenticated() || authentication instanceof AnonymousAuthenticationToken ) { return Optional.empty(); diff --git a/src/main/java/com/alibou/security/auth/AuthenticationController.java b/src/main/java/com/alibou/security/auth/AuthenticationController.java index e1d5107c..9594bf7c 100644 --- a/src/main/java/com/alibou/security/auth/AuthenticationController.java +++ b/src/main/java/com/alibou/security/auth/AuthenticationController.java @@ -16,28 +16,20 @@ @RequiredArgsConstructor public class AuthenticationController { - private final AuthenticationService service; - - @PostMapping("/register") - public ResponseEntity register( - @RequestBody RegisterRequest request - ) { - return ResponseEntity.ok(service.register(request)); - } - @PostMapping("/authenticate") - public ResponseEntity authenticate( - @RequestBody AuthenticationRequest request - ) { - return ResponseEntity.ok(service.authenticate(request)); - } - - @PostMapping("/refresh-token") - public void refreshToken( - HttpServletRequest request, - HttpServletResponse response - ) throws IOException { - service.refreshToken(request, response); - } - - + private final AuthenticationService service; + + @PostMapping("/register") + public ResponseEntity register(@RequestBody RegisterRequest request) { + return ResponseEntity.ok(service.register(request)); + } + + @PostMapping("/authenticate") + public ResponseEntity authenticate(@RequestBody AuthenticationRequest request) { + return ResponseEntity.ok(service.authenticate(request)); + } + + @PostMapping("/refresh-token") + public void refreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException { + service.refreshToken(request, response); + } } diff --git a/src/main/java/com/alibou/security/auth/AuthenticationRequest.java b/src/main/java/com/alibou/security/auth/AuthenticationRequest.java index 6d727224..8297f1e1 100644 --- a/src/main/java/com/alibou/security/auth/AuthenticationRequest.java +++ b/src/main/java/com/alibou/security/auth/AuthenticationRequest.java @@ -11,6 +11,6 @@ @NoArgsConstructor public class AuthenticationRequest { - private String email; - String password; + private String email; + String password; } diff --git a/src/main/java/com/alibou/security/auth/AuthenticationResponse.java b/src/main/java/com/alibou/security/auth/AuthenticationResponse.java index c10bbb6e..a0964bce 100644 --- a/src/main/java/com/alibou/security/auth/AuthenticationResponse.java +++ b/src/main/java/com/alibou/security/auth/AuthenticationResponse.java @@ -12,8 +12,8 @@ @NoArgsConstructor public class AuthenticationResponse { - @JsonProperty("access_token") - private String accessToken; - @JsonProperty("refresh_token") - private String refreshToken; + @JsonProperty("access_token") + private String accessToken; + @JsonProperty("refresh_token") + private String refreshToken; } diff --git a/src/main/java/com/alibou/security/auth/AuthenticationService.java b/src/main/java/com/alibou/security/auth/AuthenticationService.java index 53193a72..a70e83c7 100644 --- a/src/main/java/com/alibou/security/auth/AuthenticationService.java +++ b/src/main/java/com/alibou/security/auth/AuthenticationService.java @@ -25,96 +25,94 @@ @Service @RequiredArgsConstructor public class AuthenticationService { - private final UserRepository repository; - private final TokenRepository tokenRepository; - private final PasswordEncoder passwordEncoder; - private final JwtService jwtService; - private final AuthenticationManager authenticationManager; - public AuthenticationResponse register(RegisterRequest request) { - var user = User.builder() - .firstname(request.getFirstname()) - .lastname(request.getLastname()) - .email(request.getEmail()) - .password(passwordEncoder.encode(request.getPassword())) - .role(request.getRole()) - .build(); - var savedUser = repository.save(user); - var jwtToken = jwtService.generateToken(user); - var refreshToken = jwtService.generateRefreshToken(user); - saveUserToken(savedUser, jwtToken); - return AuthenticationResponse.builder() - .accessToken(jwtToken) - .refreshToken(refreshToken) - .build(); - } + private final UserRepository repository; + private final TokenRepository tokenRepository; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final AuthenticationManager authenticationManager; - public AuthenticationResponse authenticate(AuthenticationRequest request) { - authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken( - request.getEmail(), - request.getPassword() - ) - ); - var user = repository.findByEmail(request.getEmail()) - .orElseThrow(); - var jwtToken = jwtService.generateToken(user); - var refreshToken = jwtService.generateRefreshToken(user); - revokeAllUserTokens(user); - saveUserToken(user, jwtToken); - return AuthenticationResponse.builder() - .accessToken(jwtToken) - .refreshToken(refreshToken) - .build(); - } - - private void saveUserToken(User user, String jwtToken) { - var token = Token.builder() - .user(user) - .token(jwtToken) - .tokenType(TokenType.BEARER) - .expired(false) - .revoked(false) - .build(); - tokenRepository.save(token); - } - - private void revokeAllUserTokens(User user) { - var validUserTokens = tokenRepository.findAllValidTokenByUser(user.getId()); - if (validUserTokens.isEmpty()) - return; - validUserTokens.forEach(token -> { - token.setExpired(true); - token.setRevoked(true); - }); - tokenRepository.saveAll(validUserTokens); - } - - public void refreshToken( - HttpServletRequest request, - HttpServletResponse response - ) throws IOException { - final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); - final String refreshToken; - final String userEmail; - if (authHeader == null ||!authHeader.startsWith("Bearer ")) { - return; + public AuthenticationResponse register(RegisterRequest request) { + var user = User.builder() + .firstname(request.getFirstname()) + .lastname(request.getLastname()) + .email(request.getEmail()) + .password(passwordEncoder.encode(request.getPassword())) + .role(request.getRole()) + .build(); + var savedUser = repository.save(user); + var jwtToken = jwtService.generateToken(user); + var refreshToken = jwtService.generateRefreshToken(user); + saveUserToken(savedUser, jwtToken); + return AuthenticationResponse.builder() + .accessToken(jwtToken) + .refreshToken(refreshToken) + .build(); } - refreshToken = authHeader.substring(7); - userEmail = jwtService.extractUsername(refreshToken); - if (userEmail != null) { - var user = this.repository.findByEmail(userEmail) - .orElseThrow(); - if (jwtService.isTokenValid(refreshToken, user)) { - var accessToken = jwtService.generateToken(user); + + public AuthenticationResponse authenticate(AuthenticationRequest request) { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + request.getEmail(), + request.getPassword() + ) + ); + var user = repository.findByEmail(request.getEmail()) + .orElseThrow(); + var jwtToken = jwtService.generateToken(user); + var refreshToken = jwtService.generateRefreshToken(user); revokeAllUserTokens(user); - saveUserToken(user, accessToken); - var authResponse = AuthenticationResponse.builder() - .accessToken(accessToken) + saveUserToken(user, jwtToken); + return AuthenticationResponse.builder() + .accessToken(jwtToken) .refreshToken(refreshToken) .build(); - new ObjectMapper().writeValue(response.getOutputStream(), authResponse); - } } - } + + private void saveUserToken(User user, String jwtToken) { + var token = Token.builder() + .user(user) + .token(jwtToken) + .tokenType(TokenType.BEARER) + .expired(false) + .revoked(false) + .build(); + tokenRepository.save(token); + } + + private void revokeAllUserTokens(User user) { + var validUserTokens = tokenRepository.findAllValidTokenByUser(user.getId()); + if (validUserTokens.isEmpty()) + return; + validUserTokens.forEach(token -> { + token.setExpired(true); + token.setRevoked(true); + }); + tokenRepository.saveAll(validUserTokens); + } + + public void refreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException { + final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + final String refreshToken; + final String userEmail; + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return; + } + refreshToken = authHeader.substring(7); + userEmail = jwtService.extractUsername(refreshToken); + if (userEmail != null) { + var user = this.repository.findByEmail(userEmail) + .orElseThrow(); + if (jwtService.isTokenValid(refreshToken, user)) { + var accessToken = jwtService.generateToken(user); + revokeAllUserTokens(user); + saveUserToken(user, accessToken); + var authResponse = AuthenticationResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + new ObjectMapper().writeValue(response.getOutputStream(), authResponse); + } + } + } } diff --git a/src/main/java/com/alibou/security/auth/RegisterRequest.java b/src/main/java/com/alibou/security/auth/RegisterRequest.java index 4f51665b..c8d0e97c 100644 --- a/src/main/java/com/alibou/security/auth/RegisterRequest.java +++ b/src/main/java/com/alibou/security/auth/RegisterRequest.java @@ -12,9 +12,9 @@ @NoArgsConstructor public class RegisterRequest { - private String firstname; - private String lastname; - private String email; - private String password; - private Role role; + private String firstname; + private String lastname; + private String email; + private String password; + private Role role; } diff --git a/src/main/java/com/alibou/security/book/Book.java b/src/main/java/com/alibou/security/book/Book.java index 3f041afa..809e72eb 100644 --- a/src/main/java/com/alibou/security/book/Book.java +++ b/src/main/java/com/alibou/security/book/Book.java @@ -32,22 +32,15 @@ public class Book { private String isbn; @CreatedDate - @Column( - nullable = false, - updatable = false - ) + @Column(nullable = false, updatable = false) private LocalDateTime createDate; @LastModifiedDate @Column(insertable = false) private LocalDateTime lastModified; - @CreatedBy - @Column( - nullable = false, - updatable = false - ) + @Column(nullable = false, updatable = false) private Integer createdBy; @LastModifiedBy diff --git a/src/main/java/com/alibou/security/book/BookController.java b/src/main/java/com/alibou/security/book/BookController.java index 4c457280..c8784241 100644 --- a/src/main/java/com/alibou/security/book/BookController.java +++ b/src/main/java/com/alibou/security/book/BookController.java @@ -18,9 +18,7 @@ public class BookController { private final BookService service; @PostMapping - public ResponseEntity save( - @RequestBody BookRequest request - ) { + public ResponseEntity save(@RequestBody BookRequest request) { service.save(request); return ResponseEntity.accepted().build(); } diff --git a/src/main/java/com/alibou/security/config/ApplicationConfig.java b/src/main/java/com/alibou/security/config/ApplicationConfig.java index ae71abf5..4016929e 100644 --- a/src/main/java/com/alibou/security/config/ApplicationConfig.java +++ b/src/main/java/com/alibou/security/config/ApplicationConfig.java @@ -20,35 +20,34 @@ @RequiredArgsConstructor public class ApplicationConfig { - private final UserRepository repository; - - @Bean - public UserDetailsService userDetailsService() { - return username -> repository.findByEmail(username) - .orElseThrow(() -> new UsernameNotFoundException("User not found")); - } - - @Bean - public AuthenticationProvider authenticationProvider() { - DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); - authProvider.setUserDetailsService(userDetailsService()); - authProvider.setPasswordEncoder(passwordEncoder()); - return authProvider; - } - - @Bean - public AuditorAware auditorAware() { - return new ApplicationAuditAware(); - } - - @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { - return config.getAuthenticationManager(); - } - - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - + private final UserRepository repository; + + @Bean + public UserDetailsService userDetailsService() { + return username -> repository.findByEmail(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + } + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService()); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuditorAware auditorAware() { + return new ApplicationAuditAware(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } } diff --git a/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java b/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java index d6e55d18..ac1800c4 100644 --- a/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java +++ b/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java @@ -26,46 +26,44 @@ @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { - private final JwtService jwtService; - private final UserDetailsService userDetailsService; - private final TokenRepository tokenRepository; + private final JwtService jwtService; + private final UserDetailsService userDetailsService; + private final TokenRepository tokenRepository; - @Override - protected void doFilterInternal( - @NonNull HttpServletRequest request, - @NonNull HttpServletResponse response, - @NonNull FilterChain filterChain - ) throws ServletException, IOException { - if (request.getServletPath().contains("/api/v1/auth")) { - filterChain.doFilter(request, response); - return; + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + if (request.getServletPath().contains("/api/v1/auth")) { + filterChain.doFilter(request, response); + return; + } + final String authHeader = request.getHeader("Authorization"); + final String jwt; + final String userEmail; + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + jwt = authHeader.substring(7); + userEmail = jwtService.extractUsername(jwt); + if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail); + var isTokenValid = tokenRepository.findByToken(jwt) + .map(t -> !t.isExpired() && !t.isRevoked()) + .orElse(false); + if (jwtService.isTokenValid(jwt, userDetails) && isTokenValid) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + filterChain.doFilter(request, response); } - final String authHeader = request.getHeader("Authorization"); - final String jwt; - final String userEmail; - if (authHeader == null ||!authHeader.startsWith("Bearer ")) { - filterChain.doFilter(request, response); - return; - } - jwt = authHeader.substring(7); - userEmail = jwtService.extractUsername(jwt); - if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { - UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail); - var isTokenValid = tokenRepository.findByToken(jwt) - .map(t -> !t.isExpired() && !t.isRevoked()) - .orElse(false); - if (jwtService.isTokenValid(jwt, userDetails) && isTokenValid) { - UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() - ); - authToken.setDetails( - new WebAuthenticationDetailsSource().buildDetails(request) - ); - SecurityContextHolder.getContext().setAuthentication(authToken); - } - } - filterChain.doFilter(request, response); - } } diff --git a/src/main/java/com/alibou/security/config/JwtService.java b/src/main/java/com/alibou/security/config/JwtService.java index 9c1ed46f..e7e8a50f 100644 --- a/src/main/java/com/alibou/security/config/JwtService.java +++ b/src/main/java/com/alibou/security/config/JwtService.java @@ -5,6 +5,7 @@ import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; + import java.security.Key; import java.util.Date; import java.util.HashMap; @@ -18,78 +19,71 @@ @Service public class JwtService { - @Value("${application.security.jwt.secret-key}") - private String secretKey; - @Value("${application.security.jwt.expiration}") - private long jwtExpiration; - @Value("${application.security.jwt.refresh-token.expiration}") - private long refreshExpiration; - - public String extractUsername(String token) { - return extractClaim(token, Claims::getSubject); - } - - public T extractClaim(String token, Function claimsResolver) { - final Claims claims = extractAllClaims(token); - return claimsResolver.apply(claims); - } - - public String generateToken(UserDetails userDetails) { - return generateToken(new HashMap<>(), userDetails); - } - - public String generateToken( - Map extraClaims, - UserDetails userDetails - ) { - return buildToken(extraClaims, userDetails, jwtExpiration); - } - - public String generateRefreshToken( - UserDetails userDetails - ) { - return buildToken(new HashMap<>(), userDetails, refreshExpiration); - } - - private String buildToken( - Map extraClaims, - UserDetails userDetails, - long expiration - ) { - return Jwts - .builder() - .setClaims(extraClaims) - .setSubject(userDetails.getUsername()) - .setIssuedAt(new Date(System.currentTimeMillis())) - .setExpiration(new Date(System.currentTimeMillis() + expiration)) - .signWith(getSignInKey(), SignatureAlgorithm.HS256) - .compact(); - } - - public boolean isTokenValid(String token, UserDetails userDetails) { - final String username = extractUsername(token); - return (username.equals(userDetails.getUsername())) && !isTokenExpired(token); - } - - private boolean isTokenExpired(String token) { - return extractExpiration(token).before(new Date()); - } - - private Date extractExpiration(String token) { - return extractClaim(token, Claims::getExpiration); - } - - private Claims extractAllClaims(String token) { - return Jwts - .parserBuilder() - .setSigningKey(getSignInKey()) - .build() - .parseClaimsJws(token) - .getBody(); - } - - private Key getSignInKey() { - byte[] keyBytes = Decoders.BASE64.decode(secretKey); - return Keys.hmacShaKeyFor(keyBytes); - } + @Value("${application.security.jwt.secret-key}") + private String secretKey; + + @Value("${application.security.jwt.expiration}") + private long jwtExpiration; + + @Value("${application.security.jwt.refresh-token.expiration}") + private long refreshExpiration; + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + public String generateToken(UserDetails userDetails) { + return generateToken(new HashMap<>(), userDetails); + } + + public String generateToken(Map extraClaims, UserDetails userDetails) { + return buildToken(extraClaims, userDetails, jwtExpiration); + } + + public String generateRefreshToken(UserDetails userDetails) { + return buildToken(new HashMap<>(), userDetails, refreshExpiration); + } + + private String buildToken(Map extraClaims, UserDetails userDetails, long expiration) { + return Jwts + .builder() + .setClaims(extraClaims) + .setSubject(userDetails.getUsername()) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSignInKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public boolean isTokenValid(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return (username.equals(userDetails.getUsername())) && !isTokenExpired(token); + } + + private boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + private Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + private Claims extractAllClaims(String token) { + return Jwts + .parserBuilder() + .setSigningKey(getSignInKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + private Key getSignInKey() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + return Keys.hmacShaKeyFor(keyBytes); + } } diff --git a/src/main/java/com/alibou/security/config/LogoutService.java b/src/main/java/com/alibou/security/config/LogoutService.java index 0784565f..ca7462cc 100644 --- a/src/main/java/com/alibou/security/config/LogoutService.java +++ b/src/main/java/com/alibou/security/config/LogoutService.java @@ -13,27 +13,23 @@ @RequiredArgsConstructor public class LogoutService implements LogoutHandler { - private final TokenRepository tokenRepository; + private final TokenRepository tokenRepository; - @Override - public void logout( - HttpServletRequest request, - HttpServletResponse response, - Authentication authentication - ) { - final String authHeader = request.getHeader("Authorization"); - final String jwt; - if (authHeader == null ||!authHeader.startsWith("Bearer ")) { - return; + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + final String authHeader = request.getHeader("Authorization"); + final String jwt; + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return; + } + jwt = authHeader.substring(7); + var storedToken = tokenRepository.findByToken(jwt) + .orElse(null); + if (storedToken != null) { + storedToken.setExpired(true); + storedToken.setRevoked(true); + tokenRepository.save(storedToken); + SecurityContextHolder.clearContext(); + } } - jwt = authHeader.substring(7); - var storedToken = tokenRepository.findByToken(jwt) - .orElse(null); - if (storedToken != null) { - storedToken.setExpired(true); - storedToken.setRevoked(true); - tokenRepository.save(storedToken); - SecurityContextHolder.clearContext(); - } - } } diff --git a/src/main/java/com/alibou/security/config/SecurityConfiguration.java b/src/main/java/com/alibou/security/config/SecurityConfiguration.java index e4aefe66..3d57ff9a 100644 --- a/src/main/java/com/alibou/security/config/SecurityConfiguration.java +++ b/src/main/java/com/alibou/security/config/SecurityConfiguration.java @@ -35,7 +35,8 @@ @EnableMethodSecurity public class SecurityConfiguration { - private static final String[] WHITE_LIST_URL = {"/api/v1/auth/**", + private static final String[] WHITE_LIST_URL = { + "/api/v1/auth/**", "/v2/api-docs", "/v3/api-docs", "/v3/api-docs/**", @@ -45,14 +46,15 @@ public class SecurityConfiguration { "/configuration/security", "/swagger-ui/**", "/webjars/**", - "/swagger-ui.html"}; + "/swagger-ui.html" + }; private final JwtAuthenticationFilter jwtAuthFilter; private final AuthenticationProvider authenticationProvider; private final LogoutHandler logoutHandler; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http + return http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(req -> req.requestMatchers(WHITE_LIST_URL) @@ -73,8 +75,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .addLogoutHandler(logoutHandler) .logoutSuccessHandler((request, response, authentication) -> SecurityContextHolder.clearContext()) ) - ; - - return http.build(); + .build(); } } diff --git a/src/main/java/com/alibou/security/demo/AdminController.java b/src/main/java/com/alibou/security/demo/AdminController.java index 18ede653..f6aa3bfd 100644 --- a/src/main/java/com/alibou/security/demo/AdminController.java +++ b/src/main/java/com/alibou/security/demo/AdminController.java @@ -19,18 +19,21 @@ public class AdminController { public String get() { return "GET:: admin controller"; } + @PostMapping @PreAuthorize("hasAuthority('admin:create')") @Hidden public String post() { return "POST:: admin controller"; } + @PutMapping @PreAuthorize("hasAuthority('admin:update')") @Hidden public String put() { return "PUT:: admin controller"; } + @DeleteMapping @PreAuthorize("hasAuthority('admin:delete')") @Hidden diff --git a/src/main/java/com/alibou/security/demo/DemoController.java b/src/main/java/com/alibou/security/demo/DemoController.java index ee2c380b..84ac9d41 100644 --- a/src/main/java/com/alibou/security/demo/DemoController.java +++ b/src/main/java/com/alibou/security/demo/DemoController.java @@ -11,9 +11,8 @@ @Hidden public class DemoController { - @GetMapping - public ResponseEntity sayHello() { - return ResponseEntity.ok("Hello from secured endpoint"); - } - + @GetMapping + public ResponseEntity sayHello() { + return ResponseEntity.ok("Hello from secured endpoint"); + } } diff --git a/src/main/java/com/alibou/security/demo/ManagementController.java b/src/main/java/com/alibou/security/demo/ManagementController.java index a214a9bb..de9b613e 100644 --- a/src/main/java/com/alibou/security/demo/ManagementController.java +++ b/src/main/java/com/alibou/security/demo/ManagementController.java @@ -29,20 +29,22 @@ public class ManagementController { responseCode = "403" ) } - ) @GetMapping public String get() { return "GET:: management controller"; } + @PostMapping public String post() { return "POST:: management controller"; } + @PutMapping public String put() { return "PUT:: management controller"; } + @DeleteMapping public String delete() { return "DELETE:: management controller"; diff --git a/src/main/java/com/alibou/security/token/Token.java b/src/main/java/com/alibou/security/token/Token.java index 71f35718..31896df4 100644 --- a/src/main/java/com/alibou/security/token/Token.java +++ b/src/main/java/com/alibou/security/token/Token.java @@ -22,21 +22,21 @@ @Entity public class Token { - @Id - @GeneratedValue - public Integer id; + @Id + @GeneratedValue + public Integer id; - @Column(unique = true) - public String token; + @Column(unique = true) + public String token; - @Enumerated(EnumType.STRING) - public TokenType tokenType = TokenType.BEARER; + @Enumerated(EnumType.STRING) + public TokenType tokenType = TokenType.BEARER; - public boolean revoked; + public boolean revoked; - public boolean expired; + public boolean expired; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - public User user; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + public User user; } diff --git a/src/main/java/com/alibou/security/token/TokenRepository.java b/src/main/java/com/alibou/security/token/TokenRepository.java index 48235d87..b5e91ecd 100644 --- a/src/main/java/com/alibou/security/token/TokenRepository.java +++ b/src/main/java/com/alibou/security/token/TokenRepository.java @@ -2,17 +2,18 @@ import java.util.List; import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; public interface TokenRepository extends JpaRepository { - @Query(value = """ - select t from Token t inner join User u\s - on t.user.id = u.id\s - where u.id = :id and (t.expired = false or t.revoked = false)\s - """) - List findAllValidTokenByUser(Integer id); + @Query(value = """ + select t from Token t inner join User u\s + on t.user.id = u.id\s + where u.id = :id and (t.expired = false or t.revoked = false) + """) + List findAllValidTokenByUser(Integer id); - Optional findByToken(String token); + Optional findByToken(String token); } diff --git a/src/main/java/com/alibou/security/token/TokenType.java b/src/main/java/com/alibou/security/token/TokenType.java index 82a8cff7..35137032 100644 --- a/src/main/java/com/alibou/security/token/TokenType.java +++ b/src/main/java/com/alibou/security/token/TokenType.java @@ -1,5 +1,5 @@ package com.alibou.security.token; public enum TokenType { - BEARER + BEARER } diff --git a/src/main/java/com/alibou/security/user/Permission.java b/src/main/java/com/alibou/security/user/Permission.java index 16ae8b4c..230a6c1f 100644 --- a/src/main/java/com/alibou/security/user/Permission.java +++ b/src/main/java/com/alibou/security/user/Permission.java @@ -3,6 +3,7 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; +@Getter @RequiredArgsConstructor public enum Permission { @@ -13,10 +14,7 @@ public enum Permission { MANAGER_READ("management:read"), MANAGER_UPDATE("management:update"), MANAGER_CREATE("management:create"), - MANAGER_DELETE("management:delete") + MANAGER_DELETE("management:delete"); - ; - - @Getter private final String permission; } diff --git a/src/main/java/com/alibou/security/user/Role.java b/src/main/java/com/alibou/security/user/Role.java index 0ff9bd15..4c6ae21a 100644 --- a/src/main/java/com/alibou/security/user/Role.java +++ b/src/main/java/com/alibou/security/user/Role.java @@ -18,42 +18,40 @@ import static com.alibou.security.user.Permission.MANAGER_READ; import static com.alibou.security.user.Permission.MANAGER_UPDATE; +@Getter @RequiredArgsConstructor public enum Role { - USER(Collections.emptySet()), - ADMIN( - Set.of( - ADMIN_READ, - ADMIN_UPDATE, - ADMIN_DELETE, - ADMIN_CREATE, - MANAGER_READ, - MANAGER_UPDATE, - MANAGER_DELETE, - MANAGER_CREATE - ) - ), - MANAGER( - Set.of( - MANAGER_READ, - MANAGER_UPDATE, - MANAGER_DELETE, - MANAGER_CREATE - ) - ) - - ; - - @Getter - private final Set permissions; - - public List getAuthorities() { - var authorities = getPermissions() - .stream() - .map(permission -> new SimpleGrantedAuthority(permission.getPermission())) - .collect(Collectors.toList()); - authorities.add(new SimpleGrantedAuthority("ROLE_" + this.name())); - return authorities; - } + USER(Collections.emptySet()), + ADMIN( + Set.of( + ADMIN_READ, + ADMIN_UPDATE, + ADMIN_DELETE, + ADMIN_CREATE, + MANAGER_READ, + MANAGER_UPDATE, + MANAGER_DELETE, + MANAGER_CREATE + ) + ), + MANAGER( + Set.of( + MANAGER_READ, + MANAGER_UPDATE, + MANAGER_DELETE, + MANAGER_CREATE + ) + ); + + private final Set permissions; + + public List getAuthorities() { + var authorities = getPermissions() + .stream() + .map(permission -> new SimpleGrantedAuthority(permission.getPermission())) + .collect(Collectors.toList()); + authorities.add(new SimpleGrantedAuthority("ROLE_" + this.name())); + return authorities; + } } diff --git a/src/main/java/com/alibou/security/user/User.java b/src/main/java/com/alibou/security/user/User.java index bc4e0869..33f76f44 100644 --- a/src/main/java/com/alibou/security/user/User.java +++ b/src/main/java/com/alibou/security/user/User.java @@ -8,8 +8,10 @@ import jakarta.persistence.Id; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; + import java.util.Collection; import java.util.List; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -26,52 +28,52 @@ @Table(name = "_user") public class User implements UserDetails { - @Id - @GeneratedValue - private Integer id; - private String firstname; - private String lastname; - private String email; - private String password; + @Id + @GeneratedValue + private Integer id; + private String firstname; + private String lastname; + private String email; + private String password; - @Enumerated(EnumType.STRING) - private Role role; + @Enumerated(EnumType.STRING) + private Role role; - @OneToMany(mappedBy = "user") - private List tokens; + @OneToMany(mappedBy = "user") + private List tokens; - @Override - public Collection getAuthorities() { - return role.getAuthorities(); - } + @Override + public Collection getAuthorities() { + return role.getAuthorities(); + } - @Override - public String getPassword() { - return password; - } + @Override + public String getPassword() { + return password; + } - @Override - public String getUsername() { - return email; - } + @Override + public String getUsername() { + return email; + } - @Override - public boolean isAccountNonExpired() { - return true; - } + @Override + public boolean isAccountNonExpired() { + return true; + } - @Override - public boolean isAccountNonLocked() { - return true; - } + @Override + public boolean isAccountNonLocked() { + return true; + } - @Override - public boolean isCredentialsNonExpired() { - return true; - } + @Override + public boolean isCredentialsNonExpired() { + return true; + } - @Override - public boolean isEnabled() { - return true; - } + @Override + public boolean isEnabled() { + return true; + } } diff --git a/src/main/java/com/alibou/security/user/UserController.java b/src/main/java/com/alibou/security/user/UserController.java index 415be48e..04841546 100644 --- a/src/main/java/com/alibou/security/user/UserController.java +++ b/src/main/java/com/alibou/security/user/UserController.java @@ -17,10 +17,7 @@ public class UserController { private final UserService service; @PatchMapping - public ResponseEntity changePassword( - @RequestBody ChangePasswordRequest request, - Principal connectedUser - ) { + public ResponseEntity changePassword(@RequestBody ChangePasswordRequest request, Principal connectedUser) { service.changePassword(request, connectedUser); return ResponseEntity.ok().build(); } diff --git a/src/main/java/com/alibou/security/user/UserRepository.java b/src/main/java/com/alibou/security/user/UserRepository.java index a979ad61..7868c30a 100644 --- a/src/main/java/com/alibou/security/user/UserRepository.java +++ b/src/main/java/com/alibou/security/user/UserRepository.java @@ -1,10 +1,10 @@ package com.alibou.security.user; import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepository { - Optional findByEmail(String email); - + Optional findByEmail(String email); } diff --git a/src/main/java/com/alibou/security/user/UserService.java b/src/main/java/com/alibou/security/user/UserService.java index a17181d0..84d48762 100644 --- a/src/main/java/com/alibou/security/user/UserService.java +++ b/src/main/java/com/alibou/security/user/UserService.java @@ -13,6 +13,7 @@ public class UserService { private final PasswordEncoder passwordEncoder; private final UserRepository repository; + public void changePassword(ChangePasswordRequest request, Principal connectedUser) { var user = (User) ((UsernamePasswordAuthenticationToken) connectedUser).getPrincipal(); @@ -21,6 +22,7 @@ public void changePassword(ChangePasswordRequest request, Principal connectedUse if (!passwordEncoder.matches(request.getCurrentPassword(), user.getPassword())) { throw new IllegalStateException("Wrong password"); } + // check if the two new passwords are the same if (!request.getNewPassword().equals(request.getConfirmationPassword())) { throw new IllegalStateException("Password are not the same"); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 71b71d15..d65e97b2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,8 +1,8 @@ spring: datasource: url: jdbc:postgresql://localhost:5432/jwt_security - username: username - password: password + username: postgres + password: postgres driver-class-name: org.postgresql.Driver jpa: hibernate: diff --git a/src/test/java/com/alibou/security/SecurityApplicationTests.java b/src/test/java/com/alibou/security/SecurityApplicationTests.java index 6e2729f7..570c8bcd 100644 --- a/src/test/java/com/alibou/security/SecurityApplicationTests.java +++ b/src/test/java/com/alibou/security/SecurityApplicationTests.java @@ -6,8 +6,8 @@ @SpringBootTest class SecurityApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } From 08cdae143b3d448a10f69756a42f3f99e73965ea Mon Sep 17 00:00:00 2001 From: Oleksii Morenets Date: Thu, 14 Aug 2025 23:28:37 +0200 Subject: [PATCH 2/8] fix formatting and minor changes --- pom.xml | 2 +- .../alibou/security/SecurityApplication.java | 4 ++-- .../security/config/ApplicationConfig.java | 2 +- .../alibou/security/config/JwtService.java | 6 +++--- .../config/SecurityConfiguration.java | 19 +++--------------- .../java/com/alibou/security/user/User.java | 20 ------------------- 6 files changed, 10 insertions(+), 43 deletions(-) diff --git a/pom.xml b/pom.xml index 9c14b261..4f45c65c 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.1.4 + 3.3.2 com.alibou diff --git a/src/main/java/com/alibou/security/SecurityApplication.java b/src/main/java/com/alibou/security/SecurityApplication.java index 7293a0b1..282185d3 100644 --- a/src/main/java/com/alibou/security/SecurityApplication.java +++ b/src/main/java/com/alibou/security/SecurityApplication.java @@ -32,8 +32,8 @@ public CommandLineRunner commandLineRunner(AuthenticationService service) { System.out.println("Admin token: " + service.register(admin).getAccessToken()); var manager = RegisterRequest.builder() - .firstname("Admin") - .lastname("Admin") + .firstname("Manager") + .lastname("Manager") .email("manager@mail.com") .password("password") .role(MANAGER) diff --git a/src/main/java/com/alibou/security/config/ApplicationConfig.java b/src/main/java/com/alibou/security/config/ApplicationConfig.java index 4016929e..423e1ade 100644 --- a/src/main/java/com/alibou/security/config/ApplicationConfig.java +++ b/src/main/java/com/alibou/security/config/ApplicationConfig.java @@ -28,7 +28,7 @@ public UserDetailsService userDetailsService() { .orElseThrow(() -> new UsernameNotFoundException("User not found")); } - @Bean +// @Bean public AuthenticationProvider authenticationProvider() { DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); authProvider.setUserDetailsService(userDetailsService()); diff --git a/src/main/java/com/alibou/security/config/JwtService.java b/src/main/java/com/alibou/security/config/JwtService.java index e7e8a50f..457a1845 100644 --- a/src/main/java/com/alibou/security/config/JwtService.java +++ b/src/main/java/com/alibou/security/config/JwtService.java @@ -56,7 +56,7 @@ private String buildToken(Map extraClaims, UserDetails userDetai .setSubject(userDetails.getUsername()) .setIssuedAt(new Date(System.currentTimeMillis())) .setExpiration(new Date(System.currentTimeMillis() + expiration)) - .signWith(getSignInKey(), SignatureAlgorithm.HS256) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } @@ -76,13 +76,13 @@ private Date extractExpiration(String token) { private Claims extractAllClaims(String token) { return Jwts .parserBuilder() - .setSigningKey(getSignInKey()) + .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); } - private Key getSignInKey() { + private Key getSigningKey() { byte[] keyBytes = Decoders.BASE64.decode(secretKey); return Keys.hmacShaKeyFor(keyBytes); } diff --git a/src/main/java/com/alibou/security/config/SecurityConfiguration.java b/src/main/java/com/alibou/security/config/SecurityConfiguration.java index 3d57ff9a..f0a12688 100644 --- a/src/main/java/com/alibou/security/config/SecurityConfiguration.java +++ b/src/main/java/com/alibou/security/config/SecurityConfiguration.java @@ -37,19 +37,9 @@ public class SecurityConfiguration { private static final String[] WHITE_LIST_URL = { "/api/v1/auth/**", - "/v2/api-docs", - "/v3/api-docs", - "/v3/api-docs/**", - "/swagger-resources", - "/swagger-resources/**", - "/configuration/ui", - "/configuration/security", - "/swagger-ui/**", - "/webjars/**", - "/swagger-ui.html" }; + private final JwtAuthenticationFilter jwtAuthFilter; - private final AuthenticationProvider authenticationProvider; private final LogoutHandler logoutHandler; @Bean @@ -57,18 +47,15 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti return http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(req -> - req.requestMatchers(WHITE_LIST_URL) - .permitAll() + req.requestMatchers(WHITE_LIST_URL).permitAll() .requestMatchers("/api/v1/management/**").hasAnyRole(ADMIN.name(), MANAGER.name()) .requestMatchers(GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.name(), MANAGER_READ.name()) .requestMatchers(POST, "/api/v1/management/**").hasAnyAuthority(ADMIN_CREATE.name(), MANAGER_CREATE.name()) .requestMatchers(PUT, "/api/v1/management/**").hasAnyAuthority(ADMIN_UPDATE.name(), MANAGER_UPDATE.name()) .requestMatchers(DELETE, "/api/v1/management/**").hasAnyAuthority(ADMIN_DELETE.name(), MANAGER_DELETE.name()) - .anyRequest() - .authenticated() + .anyRequest().authenticated() ) .sessionManagement(session -> session.sessionCreationPolicy(STATELESS)) - .authenticationProvider(authenticationProvider) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .logout(logout -> logout.logoutUrl("/api/v1/auth/logout") diff --git a/src/main/java/com/alibou/security/user/User.java b/src/main/java/com/alibou/security/user/User.java index 33f76f44..2dc068bb 100644 --- a/src/main/java/com/alibou/security/user/User.java +++ b/src/main/java/com/alibou/security/user/User.java @@ -56,24 +56,4 @@ public String getPassword() { public String getUsername() { return email; } - - @Override - public boolean isAccountNonExpired() { - return true; - } - - @Override - public boolean isAccountNonLocked() { - return true; - } - - @Override - public boolean isCredentialsNonExpired() { - return true; - } - - @Override - public boolean isEnabled() { - return true; - } } From cb5fe996e43f7b170305b727a6039bdcd00024c6 Mon Sep 17 00:00:00 2001 From: Oleksii Morenets Date: Fri, 19 Sep 2025 19:39:02 +0200 Subject: [PATCH 3/8] Update README.md for jira task FRG-5 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e3e7f793..a65578fc 100644 --- a/README.md +++ b/README.md @@ -32,3 +32,4 @@ To build and run the project, follow these steps: * Run the project: mvn spring-boot:run -> The application will be available at http://localhost:8080. +END. From 856419c844107b56cfd4e584d034f319475dd42e Mon Sep 17 00:00:00 2001 From: Oleksii Morenets Date: Sun, 19 Oct 2025 21:50:23 +0200 Subject: [PATCH 4/8] Added Global Exception Handler Refactored --- README.md | 373 ++++++++++++++++-- docker-compose.yml | 5 +- .../alibou/security/SecurityApplication.java | 16 +- .../auditing/ApplicationAuditAware.java | 1 + .../auth/AuthenticationController.java | 15 +- .../security/auth/AuthenticationRequest.java | 2 +- .../security/auth/AuthenticationResponse.java | 2 + .../security/auth/AuthenticationService.java | 63 ++- .../java/com/alibou/security/book/Book.java | 8 +- .../alibou/security/book/BookController.java | 7 +- .../security/config/ApplicationConfig.java | 4 +- .../config/JwtAuthenticationFilter.java | 17 +- .../alibou/security/config/JwtService.java | 2 + .../alibou/security/config/LogoutService.java | 7 +- .../config/SecurityConfiguration.java | 36 +- .../alibou/security/demo/AdminController.java | 7 +- .../security/demo/ManagementController.java | 8 +- .../exception/GlobalExceptionHandler.java | 69 ++++ .../java/com/alibou/security/token/Token.java | 10 +- .../security/token/TokenRepository.java | 6 +- .../java/com/alibou/security/user/Role.java | 9 +- .../java/com/alibou/security/user/User.java | 22 +- .../alibou/security/user/UserController.java | 5 +- .../alibou/security/user/UserRepository.java | 4 +- 24 files changed, 530 insertions(+), 168 deletions(-) create mode 100644 src/main/java/com/alibou/security/exception/GlobalExceptionHandler.java diff --git a/README.md b/README.md index a65578fc..0f604fe5 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,338 @@ -# Spring Boot 3.0 Security with JWT Implementation -This project demonstrates the implementation of security using Spring Boot 3.0 and JSON Web Tokens (JWT). It includes the following features: - -## Features -* User registration and login with JWT authentication -* Password encryption using BCrypt -* Role-based authorization with Spring Security -* Customized access denied handling -* Logout mechanism -* Refresh token - -## Technologies -* Spring Boot 3.0 -* Spring Security -* JSON Web Tokens (JWT) -* BCrypt -* Maven - -## Getting Started -To get started with this project, you will need to have the following installed on your local machine: - -* JDK 17+ -* Maven 3+ - - -To build and run the project, follow these steps: - -* Clone the repository: `git clone https://github.com/ali-bouali/spring-boot-3-jwt-security.git` -* Navigate to the project directory: cd spring-boot-security-jwt -* Add database "jwt_security" to postgres -* Build the project: mvn clean install -* Run the project: mvn spring-boot:run - --> The application will be available at http://localhost:8080. -END. +# Spring Boot 3 JWT Security + +A production-ready Spring Boot 3 application implementing JWT-based authentication and authorization with role-based access control. + +## 🚀 Features + +- **JWT Authentication**: Secure token-based authentication using JSON Web Tokens +- **User Registration & Login**: Complete user management with encrypted password storage +- **Access & Refresh Tokens**: Dual token system for enhanced security +- **Role-Based Authorization**: Fine-grained access control with custom permissions +- **Token Management**: Automatic token revocation on logout +- **Password Encryption**: BCrypt password hashing +- **JPA Auditing**: Automatic tracking of entity creation and modification +- **OpenAPI Documentation**: Interactive API documentation with Swagger UI +- **PostgreSQL Integration**: Production-grade database support +- **Docker Compose**: Easy setup with containerized PostgreSQL and pgAdmin + +## 🛠️ Technologies + +| Technology | Version | Purpose | +|------------|---------|---------| +| Spring Boot | 3.3.2 | Application framework | +| Spring Security | 6.x | Authentication & authorization | +| Spring Data JPA | 3.x | Database access | +| JWT (jjwt) | 0.11.5 | Token generation & validation | +| PostgreSQL | Latest | Database | +| Lombok | Latest | Code generation | +| SpringDoc OpenAPI | 2.1.0 | API documentation | +| Maven | 3+ | Build tool | +| Java | 17+ | Programming language | + +## 📋 Prerequisites + +- **JDK 17** or higher +- **Maven 3.6+** +- **Docker** (optional, for running PostgreSQL) +- **PostgreSQL 14+** (if not using Docker) + +## 🔧 Installation & Setup + +### 1. Clone the Repository + +```bash +git clone https://github.com/ali-bouali/spring-boot-3-jwt-security.git +cd spring-boot-3-jwt-security +``` + +### 2. Start PostgreSQL (Docker) + +The easiest way to get started is using Docker Compose: + +```bash +docker-compose up -d +``` + +This will start: +- **PostgreSQL** on port `5432` +- **pgAdmin** on port `5050` (http://localhost:5050) + - Email: `pgadmin4@pgadmin.org` + - Password: `admin` + +### 3. Configure Application (Optional) + +The default configuration in `application.yml` should work out of the box with Docker Compose. If you need to customize: + +```yaml +spring: + datasource: + url: jdbc:postgresql://localhost:5432/jwt_security + username: postgres + password: postgres + +application: + security: + jwt: + secret-key: your-secret-key-here + expiration: 86400000 # 24 hours + refresh-token: + expiration: 604800000 # 7 days +``` + +### 4. Build the Project + +```bash +mvn clean install +``` + +### 5. Run the Application + +```bash +mvn spring-boot:run +``` + +Or run the JAR directly: + +```bash +java -jar target/security-0.0.1-SNAPSHOT.jar +``` + +The application will start on **http://localhost:8080** + +## 📚 API Documentation + +Once the application is running, access the interactive API documentation: + +- **Swagger UI**: http://localhost:8080/swagger-ui.html +- **OpenAPI JSON**: http://localhost:8080/v3/api-docs + +## 🔐 API Endpoints + +### Authentication Endpoints + +| Method | Endpoint | Description | Auth Required | +|--------|----------|-------------|---------------| +| POST | `/api/v1/auth/register` | Register new user | No | +| POST | `/api/v1/auth/authenticate` | Login and get tokens | No | +| POST | `/api/v1/auth/refresh-token` | Get new access token | Yes (Refresh Token) | +| POST | `/api/v1/auth/logout` | Logout and revoke tokens | Yes | + +### User Management + +| Method | Endpoint | Description | Auth Required | +|--------|----------|-------------|---------------| +| PATCH | `/api/v1/users` | Change user password | Yes | + +### Book Management (Sample Resource) + +| Method | Endpoint | Description | Required Role | +|--------|----------|-------------|---------------| +| GET | `/api/v1/books` | Get all books | Any authenticated user | +| POST | `/api/v1/books` | Create new book | Any authenticated user | + +### Demo Endpoints (Role Testing) + +| Method | Endpoint | Description | Required Role | +|--------|----------|-------------|---------------| +| GET | `/api/v1/demo-controller` | Public endpoint | Any authenticated user | +| GET | `/api/v1/admin/**` | Admin endpoints | ADMIN | +| GET | `/api/v1/management/**` | Management endpoints | MANAGER | + +## 🔑 Usage Examples + +### 1. Register a New User + +```bash +curl -X POST http://localhost:8080/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "firstname": "John", + "lastname": "Doe", + "email": "john.doe@example.com", + "password": "password123", + "role": "USER" + }' +``` + +**Response:** +```json +{ + "accessToken": "eyJhbGciOiJIUzI1NiJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiJ9..." +} +``` + +### 2. Authenticate (Login) + +```bash +curl -X POST http://localhost:8080/api/v1/auth/authenticate \ + -H "Content-Type: application/json" \ + -d '{ + "email": "john.doe@example.com", + "password": "password123" + }' +``` + +**Response:** +```json +{ + "accessToken": "eyJhbGciOiJIUzI1NiJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiJ9..." +} +``` + +### 3. Access Protected Endpoint (Get All Books) + +```bash +curl -X GET http://localhost:8080/api/v1/books \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." +``` + +### 4. Create a New Book + +```bash +curl -X POST http://localhost:8080/api/v1/books \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." \ + -H "Content-Type: application/json" \ + -d '{ + "author": "J.K. Rowling", + "isbn": "978-0-7475-3269-9" + }' +``` + +### 5. Refresh Access Token + +```bash +curl -X POST http://localhost:8080/api/v1/auth/refresh-token \ + -H "Authorization: Bearer " +``` + +### 6. Change Password for current authenticated User + +```bash +curl -X PATCH http://localhost:8080/api/v1/users \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "currentPassword": "password123", + "newPassword": "newPassword456", + "confirmationPassword": "newPassword456" + }' +``` + +## 🔒 Security Implementation + +### JWT Token Structure + +The application uses two types of tokens: + +1. **Access Token** (24 hours validity) + - Used for API authentication + - Contains user details and authorities + - Short-lived for security + +2. **Refresh Token** (7 days validity) + - Used to obtain new access tokens + - Stored in database for revocation + - Longer-lived for better UX + +### Token Management + +- All valid tokens are stored in the database +- On logout, all user tokens are revoked +- On new login, previous tokens are invalidated +- Tokens are checked against the database on each request + +### Role-Based Access Control + +The application supports hierarchical roles with specific permissions: + +**Roles:** +- `USER`: Basic user access +- `ADMIN`: Full system access +- `MANAGER`: Management-level access + +**Permissions:** +- `ADMIN_READ`, `ADMIN_UPDATE`, `ADMIN_CREATE`, `ADMIN_DELETE` +- `MANAGER_READ`, `MANAGER_UPDATE`, `MANAGER_CREATE`, `MANAGER_DELETE` + +### Password Security + +- Passwords are hashed using BCrypt with strength 10 +- Plain text passwords are never stored +- Password validation on change includes current password verification + +## 🏗️ Architecture Highlights + +### JWT Authentication Filter + +Custom filter (`JwtAuthenticationFilter`) intercepts all requests to: +1. Extract JWT from Authorization header +2. Validate token signature and expiration +3. Load user details from the database +4. Set authentication in Security Context + +### Database Schema + +**Main Tables:** +- `usr`: Stores user information and credentials +- `token`: Stores JWT tokens with revocation status +- `book`: Sample resource table with auditing fields (created_date, last_modified_date, created_by, last_modified_by) + +### Security Configuration + +- Stateless session management (no server-side sessions) +- JWT-based authentication +- Method-level security with `@PreAuthorize` +- CORS configuration +- Custom logout handling + +## 🧪 Testing + +Run tests with: + +```bash +mvn test +``` + +The project includes Spring Security Test support for testing secured endpoints. + +## 🐛 Troubleshooting + +### Database Connection Issues + +If you can't connect to PostgreSQL: +1. Verify Docker containers are running: `docker ps` +2. Check PostgreSQL logs: `docker logs postgres-sql` +3. Ensure port 5432 is not in use by another service + +### JWT Token Issues + +If tokens are not working: +1. Verify the `secret-key` in `application.yml` +2. Check token expiration times +3. Ensure the token is sent in the Authorization header as `Bearer ` +4. Check database for revoked tokens + +### Build Issues + +If Maven build fails: +1. Ensure JDK 17+ is installed: `java -version` +2. Clear Maven cache: `mvn clean` +3. Update dependencies: `mvn clean install -U` + +## 📝 License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## 👤 Author + +Oleksii Morenets - [GitHub](https://github.com/o-morenets) + +## 🤝 Contributing + +Contributions, issues, and feature requests are welcome! + +## ⭐ Show Your Support + +Give a ⭐️ if this project helped you! diff --git a/docker-compose.yml b/docker-compose.yml index 8c2973bf..0927d150 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,8 +3,9 @@ services: container_name: postgres-sql image: postgres environment: - POSTGRES_USER: username - POSTGRES_PASSWORD: password + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: jwt_security PGDATA: /data/postgres volumes: - postgres:/data/postgres diff --git a/src/main/java/com/alibou/security/SecurityApplication.java b/src/main/java/com/alibou/security/SecurityApplication.java index 282185d3..3796d077 100644 --- a/src/main/java/com/alibou/security/SecurityApplication.java +++ b/src/main/java/com/alibou/security/SecurityApplication.java @@ -8,8 +8,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -import static com.alibou.security.user.Role.ADMIN; -import static com.alibou.security.user.Role.MANAGER; +import static com.alibou.security.user.Role.*; @SpringBootApplication @EnableJpaAuditing(auditorAwareRef = "auditorAware") @@ -26,7 +25,7 @@ public CommandLineRunner commandLineRunner(AuthenticationService service) { .firstname("Admin") .lastname("Admin") .email("admin@mail.com") - .password("password") + .password("admin$123") .role(ADMIN) .build(); System.out.println("Admin token: " + service.register(admin).getAccessToken()); @@ -35,10 +34,19 @@ public CommandLineRunner commandLineRunner(AuthenticationService service) { .firstname("Manager") .lastname("Manager") .email("manager@mail.com") - .password("password") + .password("manager$123") .role(MANAGER) .build(); System.out.println("Manager token: " + service.register(manager).getAccessToken()); + + var user = RegisterRequest.builder() + .firstname("User") + .lastname("User") + .email("user@mail.com") + .password("user$123") + .role(USER) + .build(); + System.out.println("User token: " + service.register(user).getAccessToken()); }; } } diff --git a/src/main/java/com/alibou/security/auditing/ApplicationAuditAware.java b/src/main/java/com/alibou/security/auditing/ApplicationAuditAware.java index 5a5a1792..29b2c14e 100644 --- a/src/main/java/com/alibou/security/auditing/ApplicationAuditAware.java +++ b/src/main/java/com/alibou/security/auditing/ApplicationAuditAware.java @@ -24,6 +24,7 @@ public Optional getCurrentAuditor() { } User userPrincipal = (User) authentication.getPrincipal(); + return Optional.ofNullable(userPrincipal.getId()); } } diff --git a/src/main/java/com/alibou/security/auth/AuthenticationController.java b/src/main/java/com/alibou/security/auth/AuthenticationController.java index 9594bf7c..7c1340ff 100644 --- a/src/main/java/com/alibou/security/auth/AuthenticationController.java +++ b/src/main/java/com/alibou/security/auth/AuthenticationController.java @@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -16,20 +17,24 @@ @RequiredArgsConstructor public class AuthenticationController { - private final AuthenticationService service; + private final AuthenticationService authenticationService; @PostMapping("/register") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity register(@RequestBody RegisterRequest request) { - return ResponseEntity.ok(service.register(request)); + return ResponseEntity.ok(authenticationService.register(request)); } @PostMapping("/authenticate") public ResponseEntity authenticate(@RequestBody AuthenticationRequest request) { - return ResponseEntity.ok(service.authenticate(request)); + return ResponseEntity.ok(authenticationService.authenticate(request)); } @PostMapping("/refresh-token") - public void refreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException { - service.refreshToken(request, response); + public ResponseEntity refreshToken( + HttpServletRequest request, + HttpServletResponse response + ) throws IOException { + return ResponseEntity.ok(authenticationService.refreshToken(request, response)); } } diff --git a/src/main/java/com/alibou/security/auth/AuthenticationRequest.java b/src/main/java/com/alibou/security/auth/AuthenticationRequest.java index 8297f1e1..d061e496 100644 --- a/src/main/java/com/alibou/security/auth/AuthenticationRequest.java +++ b/src/main/java/com/alibou/security/auth/AuthenticationRequest.java @@ -12,5 +12,5 @@ public class AuthenticationRequest { private String email; - String password; + private String password; } diff --git a/src/main/java/com/alibou/security/auth/AuthenticationResponse.java b/src/main/java/com/alibou/security/auth/AuthenticationResponse.java index a0964bce..274f3109 100644 --- a/src/main/java/com/alibou/security/auth/AuthenticationResponse.java +++ b/src/main/java/com/alibou/security/auth/AuthenticationResponse.java @@ -14,6 +14,8 @@ public class AuthenticationResponse { @JsonProperty("access_token") private String accessToken; + @JsonProperty("refresh_token") private String refreshToken; + } diff --git a/src/main/java/com/alibou/security/auth/AuthenticationService.java b/src/main/java/com/alibou/security/auth/AuthenticationService.java index a70e83c7..185ee596 100644 --- a/src/main/java/com/alibou/security/auth/AuthenticationService.java +++ b/src/main/java/com/alibou/security/auth/AuthenticationService.java @@ -4,20 +4,15 @@ import com.alibou.security.token.Token; import com.alibou.security.token.TokenRepository; import com.alibou.security.token.TokenType; -import com.alibou.security.user.Role; import com.alibou.security.user.User; import com.alibou.security.user.UserRepository; -import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Service; import java.io.IOException; @@ -44,6 +39,7 @@ public AuthenticationResponse register(RegisterRequest request) { var jwtToken = jwtService.generateToken(user); var refreshToken = jwtService.generateRefreshToken(user); saveUserToken(savedUser, jwtToken); + return AuthenticationResponse.builder() .accessToken(jwtToken) .refreshToken(refreshToken) @@ -57,18 +53,46 @@ public AuthenticationResponse authenticate(AuthenticationRequest request) { request.getPassword() ) ); - var user = repository.findByEmail(request.getEmail()) - .orElseThrow(); + var user = repository.findByEmail(request.getEmail()).orElseThrow(); var jwtToken = jwtService.generateToken(user); var refreshToken = jwtService.generateRefreshToken(user); revokeAllUserTokens(user); saveUserToken(user, jwtToken); + return AuthenticationResponse.builder() .accessToken(jwtToken) .refreshToken(refreshToken) .build(); } + public AuthenticationResponse refreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException { + final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + throw new IllegalStateException("Missing or invalid Authorization header"); + } + + final String refreshToken = authHeader.substring(7); + final String userEmail = jwtService.extractUsername(refreshToken); + if (userEmail == null) { + throw new IllegalStateException("Invalid token"); + } + + var user = this.repository.findByEmail(userEmail).orElseThrow(); + if (!jwtService.isTokenValid(refreshToken, user)) { + throw new IllegalStateException("Invalid or expired refresh token"); + } + + var accessToken = jwtService.generateToken(user); + revokeAllUserTokens(user); + saveUserToken(user, accessToken); + + return AuthenticationResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + private void saveUserToken(User user, String jwtToken) { var token = Token.builder() .user(user) @@ -90,29 +114,4 @@ private void revokeAllUserTokens(User user) { }); tokenRepository.saveAll(validUserTokens); } - - public void refreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException { - final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); - final String refreshToken; - final String userEmail; - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - return; - } - refreshToken = authHeader.substring(7); - userEmail = jwtService.extractUsername(refreshToken); - if (userEmail != null) { - var user = this.repository.findByEmail(userEmail) - .orElseThrow(); - if (jwtService.isTokenValid(refreshToken, user)) { - var accessToken = jwtService.generateToken(user); - revokeAllUserTokens(user); - saveUserToken(user, accessToken); - var authResponse = AuthenticationResponse.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .build(); - new ObjectMapper().writeValue(response.getOutputStream(), authResponse); - } - } - } } diff --git a/src/main/java/com/alibou/security/book/Book.java b/src/main/java/com/alibou/security/book/Book.java index 809e72eb..818ef2f4 100644 --- a/src/main/java/com/alibou/security/book/Book.java +++ b/src/main/java/com/alibou/security/book/Book.java @@ -1,10 +1,6 @@ package com.alibou.security.book; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EntityListeners; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -28,7 +24,9 @@ public class Book { @Id @GeneratedValue private Integer id; + private String author; + private String isbn; @CreatedDate diff --git a/src/main/java/com/alibou/security/book/BookController.java b/src/main/java/com/alibou/security/book/BookController.java index c8784241..479fec02 100644 --- a/src/main/java/com/alibou/security/book/BookController.java +++ b/src/main/java/com/alibou/security/book/BookController.java @@ -2,11 +2,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.util.List; @@ -20,6 +16,7 @@ public class BookController { @PostMapping public ResponseEntity save(@RequestBody BookRequest request) { service.save(request); + return ResponseEntity.accepted().build(); } diff --git a/src/main/java/com/alibou/security/config/ApplicationConfig.java b/src/main/java/com/alibou/security/config/ApplicationConfig.java index 423e1ade..85f799a3 100644 --- a/src/main/java/com/alibou/security/config/ApplicationConfig.java +++ b/src/main/java/com/alibou/security/config/ApplicationConfig.java @@ -2,7 +2,6 @@ import com.alibou.security.auditing.ApplicationAuditAware; import com.alibou.security.user.UserRepository; -import jakarta.persistence.criteria.CriteriaBuilder; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -28,11 +27,12 @@ public UserDetailsService userDetailsService() { .orElseThrow(() -> new UsernameNotFoundException("User not found")); } -// @Bean + // @Bean public AuthenticationProvider authenticationProvider() { DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); authProvider.setUserDetailsService(userDetailsService()); authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; } diff --git a/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java b/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java index ac1800c4..151a60c6 100644 --- a/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java +++ b/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java @@ -5,13 +5,6 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; - -import java.beans.Transient; -import java.io.IOException; -import java.security.Security; - -import jakarta.transaction.TransactionScoped; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.lang.NonNull; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -22,6 +15,8 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; + @Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { @@ -36,17 +31,23 @@ protected void doFilterInternal( @NonNull HttpServletResponse response, @NonNull FilterChain filterChain ) throws ServletException, IOException { - if (request.getServletPath().contains("/api/v1/auth")) { + final String servletPath = request.getServletPath(); + if (servletPath.equals("/api/v1/auth/authenticate") || + servletPath.equals("/api/v1/auth/refresh-token")) { filterChain.doFilter(request, response); + return; } + final String authHeader = request.getHeader("Authorization"); final String jwt; final String userEmail; if (authHeader == null || !authHeader.startsWith("Bearer ")) { filterChain.doFilter(request, response); + return; } + jwt = authHeader.substring(7); userEmail = jwtService.extractUsername(jwt); if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { diff --git a/src/main/java/com/alibou/security/config/JwtService.java b/src/main/java/com/alibou/security/config/JwtService.java index 457a1845..ce190f63 100644 --- a/src/main/java/com/alibou/security/config/JwtService.java +++ b/src/main/java/com/alibou/security/config/JwtService.java @@ -62,6 +62,7 @@ private String buildToken(Map extraClaims, UserDetails userDetai public boolean isTokenValid(String token, UserDetails userDetails) { final String username = extractUsername(token); + return (username.equals(userDetails.getUsername())) && !isTokenExpired(token); } @@ -84,6 +85,7 @@ private Claims extractAllClaims(String token) { private Key getSigningKey() { byte[] keyBytes = Decoders.BASE64.decode(secretKey); + return Keys.hmacShaKeyFor(keyBytes); } } diff --git a/src/main/java/com/alibou/security/config/LogoutService.java b/src/main/java/com/alibou/security/config/LogoutService.java index ca7462cc..54118874 100644 --- a/src/main/java/com/alibou/security/config/LogoutService.java +++ b/src/main/java/com/alibou/security/config/LogoutService.java @@ -18,13 +18,12 @@ public class LogoutService implements LogoutHandler { @Override public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { final String authHeader = request.getHeader("Authorization"); - final String jwt; + if (authHeader == null || !authHeader.startsWith("Bearer ")) { return; } - jwt = authHeader.substring(7); - var storedToken = tokenRepository.findByToken(jwt) - .orElse(null); + final String jwt = authHeader.substring(7); + var storedToken = tokenRepository.findByToken(jwt).orElse(null); if (storedToken != null) { storedToken.setExpired(true); storedToken.setRevoked(true); diff --git a/src/main/java/com/alibou/security/config/SecurityConfiguration.java b/src/main/java/com/alibou/security/config/SecurityConfiguration.java index f0a12688..5777a190 100644 --- a/src/main/java/com/alibou/security/config/SecurityConfiguration.java +++ b/src/main/java/com/alibou/security/config/SecurityConfiguration.java @@ -3,7 +3,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.authentication.AuthenticationProvider; 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; @@ -13,20 +12,10 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutHandler; -import static com.alibou.security.user.Permission.ADMIN_CREATE; -import static com.alibou.security.user.Permission.ADMIN_DELETE; -import static com.alibou.security.user.Permission.ADMIN_READ; -import static com.alibou.security.user.Permission.ADMIN_UPDATE; -import static com.alibou.security.user.Permission.MANAGER_CREATE; -import static com.alibou.security.user.Permission.MANAGER_DELETE; -import static com.alibou.security.user.Permission.MANAGER_READ; -import static com.alibou.security.user.Permission.MANAGER_UPDATE; +import static com.alibou.security.user.Permission.*; import static com.alibou.security.user.Role.ADMIN; import static com.alibou.security.user.Role.MANAGER; -import static org.springframework.http.HttpMethod.DELETE; -import static org.springframework.http.HttpMethod.GET; -import static org.springframework.http.HttpMethod.POST; -import static org.springframework.http.HttpMethod.PUT; +import static org.springframework.http.HttpMethod.*; import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; @Configuration @@ -36,7 +25,11 @@ public class SecurityConfiguration { private static final String[] WHITE_LIST_URL = { - "/api/v1/auth/**", + "/api/v1/auth/authenticate", + "/api/v1/auth/refresh-token", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html" }; private final JwtAuthenticationFilter jwtAuthFilter; @@ -47,7 +40,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti return http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(req -> - req.requestMatchers(WHITE_LIST_URL).permitAll() + req + .requestMatchers(WHITE_LIST_URL).permitAll() .requestMatchers("/api/v1/management/**").hasAnyRole(ADMIN.name(), MANAGER.name()) .requestMatchers(GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.name(), MANAGER_READ.name()) .requestMatchers(POST, "/api/v1/management/**").hasAnyAuthority(ADMIN_CREATE.name(), MANAGER_CREATE.name()) @@ -57,6 +51,18 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ) .sessionManagement(session -> session.sessionCreationPolicy(STATELESS)) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(exception -> exception + .authenticationEntryPoint((request, response, authException) -> { + response.setStatus(401); + response.setContentType("application/json"); + response.getWriter().write("{\"error\":\"Unauthorized\",\"message\":\"" + authException.getMessage() + "\"}"); + }) + .accessDeniedHandler((request, response, accessDeniedException) -> { + response.setStatus(403); + response.setContentType("application/json"); + response.getWriter().write("{\"error\":\"Forbidden\",\"message\":\"Access denied\"}"); + }) + ) .logout(logout -> logout.logoutUrl("/api/v1/auth/logout") .addLogoutHandler(logoutHandler) diff --git a/src/main/java/com/alibou/security/demo/AdminController.java b/src/main/java/com/alibou/security/demo/AdminController.java index f6aa3bfd..85a2f3a6 100644 --- a/src/main/java/com/alibou/security/demo/AdminController.java +++ b/src/main/java/com/alibou/security/demo/AdminController.java @@ -2,12 +2,7 @@ import io.swagger.v3.oas.annotations.Hidden; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/admin") diff --git a/src/main/java/com/alibou/security/demo/ManagementController.java b/src/main/java/com/alibou/security/demo/ManagementController.java index de9b613e..611f5190 100644 --- a/src/main/java/com/alibou/security/demo/ManagementController.java +++ b/src/main/java/com/alibou/security/demo/ManagementController.java @@ -3,19 +3,13 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/management") @Tag(name = "Management") public class ManagementController { - @Operation( description = "Get endpoint for manager", summary = "This is a summary for management get endpoint", diff --git a/src/main/java/com/alibou/security/exception/GlobalExceptionHandler.java b/src/main/java/com/alibou/security/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..6ae6c5a1 --- /dev/null +++ b/src/main/java/com/alibou/security/exception/GlobalExceptionHandler.java @@ -0,0 +1,69 @@ +package com.alibou.security.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity> handleIllegalStateException(IllegalStateException ex) { + Map errorResponse = new HashMap<>(); + errorResponse.put("timestamp", LocalDateTime.now()); + errorResponse.put("status", HttpStatus.BAD_REQUEST.value()); + errorResponse.put("error", "Bad Request"); + errorResponse.put("message", ex.getMessage()); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(errorResponse); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity> handleBadCredentialsException(BadCredentialsException ex) { + Map errorResponse = new HashMap<>(); + errorResponse.put("timestamp", LocalDateTime.now()); + errorResponse.put("status", HttpStatus.UNAUTHORIZED.value()); + errorResponse.put("error", "Unauthorized"); + errorResponse.put("message", "Invalid credentials"); + + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(errorResponse); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDeniedException(AccessDeniedException ex) { + Map errorResponse = new HashMap<>(); + errorResponse.put("timestamp", LocalDateTime.now()); + errorResponse.put("status", HttpStatus.FORBIDDEN.value()); + errorResponse.put("error", "Forbidden"); + errorResponse.put("message", "Access denied"); + + return ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body(errorResponse); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneralException(Exception ex) { + Map errorResponse = new HashMap<>(); + errorResponse.put("timestamp", LocalDateTime.now()); + errorResponse.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); + errorResponse.put("error", "Internal Server Error"); + errorResponse.put("message", ex.getMessage()); + + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(errorResponse); + } +} + diff --git a/src/main/java/com/alibou/security/token/Token.java b/src/main/java/com/alibou/security/token/Token.java index 31896df4..85f46522 100644 --- a/src/main/java/com/alibou/security/token/Token.java +++ b/src/main/java/com/alibou/security/token/Token.java @@ -1,15 +1,7 @@ package com.alibou.security.token; import com.alibou.security.user.User; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; diff --git a/src/main/java/com/alibou/security/token/TokenRepository.java b/src/main/java/com/alibou/security/token/TokenRepository.java index b5e91ecd..58ed1852 100644 --- a/src/main/java/com/alibou/security/token/TokenRepository.java +++ b/src/main/java/com/alibou/security/token/TokenRepository.java @@ -1,11 +1,11 @@ package com.alibou.security.token; -import java.util.List; -import java.util.Optional; - import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import java.util.List; +import java.util.Optional; + public interface TokenRepository extends JpaRepository { @Query(value = """ diff --git a/src/main/java/com/alibou/security/user/Role.java b/src/main/java/com/alibou/security/user/Role.java index 4c6ae21a..04220b4a 100644 --- a/src/main/java/com/alibou/security/user/Role.java +++ b/src/main/java/com/alibou/security/user/Role.java @@ -9,14 +9,7 @@ import java.util.Set; import java.util.stream.Collectors; -import static com.alibou.security.user.Permission.ADMIN_CREATE; -import static com.alibou.security.user.Permission.ADMIN_DELETE; -import static com.alibou.security.user.Permission.ADMIN_READ; -import static com.alibou.security.user.Permission.ADMIN_UPDATE; -import static com.alibou.security.user.Permission.MANAGER_CREATE; -import static com.alibou.security.user.Permission.MANAGER_DELETE; -import static com.alibou.security.user.Permission.MANAGER_READ; -import static com.alibou.security.user.Permission.MANAGER_UPDATE; +import static com.alibou.security.user.Permission.*; @Getter @RequiredArgsConstructor diff --git a/src/main/java/com/alibou/security/user/User.java b/src/main/java/com/alibou/security/user/User.java index 2dc068bb..01c33f4b 100644 --- a/src/main/java/com/alibou/security/user/User.java +++ b/src/main/java/com/alibou/security/user/User.java @@ -1,39 +1,35 @@ package com.alibou.security.user; import com.alibou.security.token.Token; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.OneToMany; -import jakarta.persistence.Table; - -import java.util.Collection; -import java.util.List; - +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; 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; + @Data @Builder @NoArgsConstructor @AllArgsConstructor @Entity -@Table(name = "_user") +@Table(name = "usr") public class User implements UserDetails { @Id @GeneratedValue private Integer id; + private String firstname; + private String lastname; + private String email; + private String password; @Enumerated(EnumType.STRING) diff --git a/src/main/java/com/alibou/security/user/UserController.java b/src/main/java/com/alibou/security/user/UserController.java index 04841546..40e77655 100644 --- a/src/main/java/com/alibou/security/user/UserController.java +++ b/src/main/java/com/alibou/security/user/UserController.java @@ -14,11 +14,12 @@ @RequiredArgsConstructor public class UserController { - private final UserService service; + private final UserService userService; @PatchMapping public ResponseEntity changePassword(@RequestBody ChangePasswordRequest request, Principal connectedUser) { - service.changePassword(request, connectedUser); + userService.changePassword(request, connectedUser); + return ResponseEntity.ok().build(); } } diff --git a/src/main/java/com/alibou/security/user/UserRepository.java b/src/main/java/com/alibou/security/user/UserRepository.java index 7868c30a..26df9d1d 100644 --- a/src/main/java/com/alibou/security/user/UserRepository.java +++ b/src/main/java/com/alibou/security/user/UserRepository.java @@ -1,9 +1,9 @@ package com.alibou.security.user; -import java.util.Optional; - import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface UserRepository extends JpaRepository { Optional findByEmail(String email); From ecfd1bb29157c1b4ecbac0730158cc736d941187 Mon Sep 17 00:00:00 2001 From: Oleksii Morenets Date: Mon, 20 Oct 2025 11:34:22 +0200 Subject: [PATCH 5/8] Swagger endpoints whitelisted in JWT Auth Filter --- README.md | 3 + .../config/JwtAuthenticationFilter.java | 7 ++- .../alibou/security/config/OpenApiConfig.java | 20 +----- .../alibou/security/demo/AdminController.java | 6 +- .../security/demo/ManagementController.java | 62 ++++++++++++++++++- src/main/resources/application.yml | 7 +++ 6 files changed, 80 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 0f604fe5..ef810149 100644 --- a/README.md +++ b/README.md @@ -104,8 +104,11 @@ The application will start on **http://localhost:8080** Once the application is running, access the interactive API documentation: - **Swagger UI**: http://localhost:8080/swagger-ui.html +- **Alternative Swagger URL**: http://localhost:8080/swagger-ui/index.html - **OpenAPI JSON**: http://localhost:8080/v3/api-docs +> **Note**: Make sure the application is running and connected to the database before accessing Swagger UI. If you get a connection error, restart the application after starting the PostgreSQL container. + ## 🔐 API Endpoints ### Authentication Endpoints diff --git a/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java b/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java index 151a60c6..dcc86be6 100644 --- a/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java +++ b/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java @@ -32,8 +32,13 @@ protected void doFilterInternal( @NonNull FilterChain filterChain ) throws ServletException, IOException { final String servletPath = request.getServletPath(); + + // Skip JWT processing for public endpoints if (servletPath.equals("/api/v1/auth/authenticate") || - servletPath.equals("/api/v1/auth/refresh-token")) { + servletPath.equals("/api/v1/auth/refresh-token") || + servletPath.startsWith("/v3/api-docs") || + servletPath.startsWith("/swagger-ui") || + servletPath.equals("/swagger-ui.html")) { filterChain.doFilter(request, response); return; diff --git a/src/main/java/com/alibou/security/config/OpenApiConfig.java b/src/main/java/com/alibou/security/config/OpenApiConfig.java index 58d6929f..a6ea92cb 100644 --- a/src/main/java/com/alibou/security/config/OpenApiConfig.java +++ b/src/main/java/com/alibou/security/config/OpenApiConfig.java @@ -3,38 +3,22 @@ import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; -import io.swagger.v3.oas.annotations.info.Contact; import io.swagger.v3.oas.annotations.info.Info; -import io.swagger.v3.oas.annotations.info.License; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityScheme; import io.swagger.v3.oas.annotations.servers.Server; @OpenAPIDefinition( info = @Info( - contact = @Contact( - name = "Alibou", - email = "contact@aliboucoding.com", - url = "https://aliboucoding.com/course" - ), + title = "OpenApi specification", description = "OpenApi documentation for Spring Security", - title = "OpenApi specification - Alibou", - version = "1.0", - license = @License( - name = "Licence name", - url = "https://some-url.com" - ), - termsOfService = "Terms of service" + version = "1.0" ), servers = { @Server( description = "Local ENV", url = "http://localhost:8080" ), - @Server( - description = "PROD ENV", - url = "https://aliboucoding.com/course" - ) }, security = { @SecurityRequirement( diff --git a/src/main/java/com/alibou/security/demo/AdminController.java b/src/main/java/com/alibou/security/demo/AdminController.java index 85a2f3a6..410bb0f3 100644 --- a/src/main/java/com/alibou/security/demo/AdminController.java +++ b/src/main/java/com/alibou/security/demo/AdminController.java @@ -1,12 +1,13 @@ package com.alibou.security.demo; -import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/admin") @PreAuthorize("hasRole('ADMIN')") +@Tag(name = "Admin") public class AdminController { @GetMapping @@ -17,21 +18,18 @@ public String get() { @PostMapping @PreAuthorize("hasAuthority('admin:create')") - @Hidden public String post() { return "POST:: admin controller"; } @PutMapping @PreAuthorize("hasAuthority('admin:update')") - @Hidden public String put() { return "PUT:: admin controller"; } @DeleteMapping @PreAuthorize("hasAuthority('admin:delete')") - @Hidden public String delete() { return "DELETE:: admin controller"; } diff --git a/src/main/java/com/alibou/security/demo/ManagementController.java b/src/main/java/com/alibou/security/demo/ManagementController.java index 611f5190..3d17e470 100644 --- a/src/main/java/com/alibou/security/demo/ManagementController.java +++ b/src/main/java/com/alibou/security/demo/ManagementController.java @@ -11,8 +11,8 @@ public class ManagementController { @Operation( - description = "Get endpoint for manager", - summary = "This is a summary for management get endpoint", + summary = "Get endpoint for manager", + description = "Role `MANAGER` required", responses = { @ApiResponse( description = "Success", @@ -20,6 +20,10 @@ public class ManagementController { ), @ApiResponse( description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", responseCode = "403" ) } @@ -29,16 +33,70 @@ public String get() { return "GET:: management controller"; } + @Operation( + summary = "Post endpoint for manager", + description = "Role `MANAGER` required", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) @PostMapping public String post() { return "POST:: management controller"; } + @Operation( + summary = "Put endpoint for manager", + description = "Role `MANAGER` required", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) @PutMapping public String put() { return "PUT:: management controller"; } + @Operation( + summary = "Delete endpoint for manager", + description = "Role `MANAGER` required", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) @DeleteMapping public String delete() { return "DELETE:: management controller"; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d65e97b2..cef44a3a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,6 +14,13 @@ spring: database: postgresql database-platform: org.hibernate.dialect.PostgreSQLDialect +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + enabled: true + application: security: jwt: From b25184d6278e620d55f860969773b163acf221fc Mon Sep 17 00:00:00 2001 From: Oleksii Morenets Date: Mon, 20 Oct 2025 20:41:04 +0200 Subject: [PATCH 6/8] Grand refactoring --- README.md | 173 +++++++++++++++++- http/change-password.http | 45 ----- http/http-test.http | 15 +- http/jpa-auditing.http | 44 ----- .../auth/AuthenticationController.java | 40 ---- .../alibou/security/book/BookController.java | 27 --- .../alibou/security/demo/AdminController.java | 36 ---- .../security/SecurityApplication.java | 8 +- .../auditing/ApplicationAuditAware.java | 4 +- .../auth/AuthenticationController.java | 84 +++++++++ .../security/auth/AuthenticationRequest.java | 2 +- .../security/auth/AuthenticationResponse.java | 2 +- .../security/auth/AuthenticationService.java | 21 +-- .../security/auth/RegisterRequest.java | 4 +- .../{alibou => omore}/security/book/Book.java | 2 +- .../omore/security/book/BookController.java | 67 +++++++ .../security/book/BookRepository.java | 2 +- .../security/book/BookRequest.java | 6 +- .../security/book/BookService.java | 2 +- .../security/config/ApplicationConfig.java | 17 +- .../omore/security/config/JacksonConfig.java | 20 ++ .../config/JwtAuthenticationFilter.java | 4 +- .../security/config/JwtService.java | 2 +- .../security/config/LogoutService.java | 4 +- .../security/config/OpenApiConfig.java | 2 +- .../config/SecurityConfiguration.java | 41 +++-- .../omore/security/demo/AdminController.java | 110 +++++++++++ .../security/demo/DemoController.java | 8 +- .../security/demo/ManagementController.java | 30 +-- .../exception/GlobalExceptionHandler.java | 6 +- .../security/token/Token.java | 4 +- .../security/token/TokenRepository.java | 2 +- .../security/token/TokenType.java | 2 +- .../security/user/ChangePasswordRequest.java | 6 +- .../security/user/Permission.java | 6 +- .../{alibou => omore}/security/user/Role.java | 32 +++- .../{alibou => omore}/security/user/User.java | 4 +- .../security/user/UserController.java | 6 +- .../security/user/UserRepository.java | 2 +- .../security/user/UserService.java | 2 +- .../security/SecurityApplicationTests.java | 2 +- 41 files changed, 585 insertions(+), 311 deletions(-) delete mode 100644 http/change-password.http delete mode 100644 http/jpa-auditing.http delete mode 100644 src/main/java/com/alibou/security/auth/AuthenticationController.java delete mode 100644 src/main/java/com/alibou/security/book/BookController.java delete mode 100644 src/main/java/com/alibou/security/demo/AdminController.java rename src/main/java/com/{alibou => omore}/security/SecurityApplication.java (90%) rename src/main/java/com/{alibou => omore}/security/auditing/ApplicationAuditAware.java (92%) create mode 100644 src/main/java/com/omore/security/auth/AuthenticationController.java rename src/main/java/com/{alibou => omore}/security/auth/AuthenticationRequest.java (88%) rename src/main/java/com/{alibou => omore}/security/auth/AuthenticationResponse.java (92%) rename src/main/java/com/{alibou => omore}/security/auth/AuthenticationService.java (90%) rename src/main/java/com/{alibou => omore}/security/auth/RegisterRequest.java (82%) rename src/main/java/com/{alibou => omore}/security/book/Book.java (97%) create mode 100644 src/main/java/com/omore/security/book/BookController.java rename src/main/java/com/{alibou => omore}/security/book/BookRepository.java (80%) rename src/main/java/com/{alibou => omore}/security/book/BookRequest.java (58%) rename src/main/java/com/{alibou => omore}/security/book/BookService.java (94%) rename src/main/java/com/{alibou => omore}/security/config/ApplicationConfig.java (69%) create mode 100644 src/main/java/com/omore/security/config/JacksonConfig.java rename src/main/java/com/{alibou => omore}/security/config/JwtAuthenticationFilter.java (97%) rename src/main/java/com/{alibou => omore}/security/config/JwtService.java (98%) rename src/main/java/com/{alibou => omore}/security/config/LogoutService.java (93%) rename src/main/java/com/{alibou => omore}/security/config/OpenApiConfig.java (97%) rename src/main/java/com/{alibou => omore}/security/config/SecurityConfiguration.java (67%) create mode 100644 src/main/java/com/omore/security/demo/AdminController.java rename src/main/java/com/{alibou => omore}/security/demo/DemoController.java (74%) rename src/main/java/com/{alibou => omore}/security/demo/ManagementController.java (84%) rename src/main/java/com/{alibou => omore}/security/exception/GlobalExceptionHandler.java (94%) rename src/main/java/com/{alibou => omore}/security/token/Token.java (88%) rename src/main/java/com/{alibou => omore}/security/token/TokenRepository.java (93%) rename src/main/java/com/{alibou => omore}/security/token/TokenType.java (52%) rename src/main/java/com/{alibou => omore}/security/user/ChangePasswordRequest.java (63%) rename src/main/java/com/{alibou => omore}/security/user/Permission.java (93%) rename src/main/java/com/{alibou => omore}/security/user/Role.java (67%) rename src/main/java/com/{alibou => omore}/security/user/User.java (93%) rename src/main/java/com/{alibou => omore}/security/user/UserController.java (84%) rename src/main/java/com/{alibou => omore}/security/user/UserRepository.java (86%) rename src/main/java/com/{alibou => omore}/security/user/UserService.java (97%) rename src/test/java/com/{alibou => omore}/security/SecurityApplicationTests.java (86%) diff --git a/README.md b/README.md index ef810149..30ed8e96 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,9 @@ Once the application is running, access the interactive API documentation: ### User Management -| Method | Endpoint | Description | Auth Required | -|--------|----------|-------------|---------------| -| PATCH | `/api/v1/users` | Change user password | Yes | +| Method | Endpoint | Description | Auth Required | +|--------|-----------------------------|-------------|---------------| +| PATCH | `/api/v1/users/me/password` | Change user password | Yes | ### Book Management (Sample Resource) @@ -135,10 +135,10 @@ Once the application is running, access the interactive API documentation: ### Demo Endpoints (Role Testing) -| Method | Endpoint | Description | Required Role | -|--------|----------|-------------|---------------| -| GET | `/api/v1/demo-controller` | Public endpoint | Any authenticated user | -| GET | `/api/v1/admin/**` | Admin endpoints | ADMIN | +| Method | Endpoint | Description | Required Role | +|--------|------------------------|-------------|---------------| +| GET | `/api/v1/demo` | Public endpoint | Any authenticated user | +| GET | `/api/v1/admin/**` | Admin endpoints | ADMIN | | GET | `/api/v1/management/**` | Management endpoints | MANAGER | ## 🔑 Usage Examples @@ -213,7 +213,7 @@ curl -X POST http://localhost:8080/api/v1/auth/refresh-token \ ### 6. Change Password for current authenticated User ```bash -curl -X PATCH http://localhost:8080/api/v1/users \ +curl -X PATCH http://localhost:8080/api/v1/users/me/password \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ @@ -259,6 +259,163 @@ The application supports hierarchical roles with specific permissions: - `ADMIN_READ`, `ADMIN_UPDATE`, `ADMIN_CREATE`, `ADMIN_DELETE` - `MANAGER_READ`, `MANAGER_UPDATE`, `MANAGER_CREATE`, `MANAGER_DELETE` +## 🎭 Managing User Authorities + +### How Roles and Authorities Work + +The application uses a flexible permission system where **roles contain sets of permissions**. Each user is assigned a role, and that role determines what authorities (permissions) they have. + +### Authority Structure + +When a user logs in, their role is converted into a list of authorities: + +| Role | Authorities Granted | +|------|-------------------| +| **USER** | `ROLE_USER` + custom permissions (if any) | +| **MANAGER** | `ROLE_MANAGER` + `management:create`, `management:read`, `management:update`, `management:delete` | +| **ADMIN** | `ROLE_ADMIN` + all admin permissions + all management permissions | + +### Adding/Removing Permissions from Roles + +To customize what permissions each role has, edit the `Role.java` enum: + +**Location:** `src/main/java/com/omore/security/user/Role.java` + +```java +@Getter +@RequiredArgsConstructor +public enum Role { + + USER( + Set.of( + // Add permissions here for USER role + // Example: ADMIN_CREATE, MANAGER_READ, etc. + ) + ), + + ADMIN( + Set.of( + ADMIN_CREATE, + ADMIN_READ, + ADMIN_UPDATE, + ADMIN_DELETE, + MANAGER_CREATE, // ADMIN also has MANAGER permissions + MANAGER_READ, + MANAGER_UPDATE, + MANAGER_DELETE + ) + ), + + MANAGER( + Set.of( + MANAGER_CREATE, + MANAGER_READ, + MANAGER_UPDATE, + MANAGER_DELETE + ) + ); + + private final Set permissions; +} +``` + +### Example: Give USER Role Admin Create Permission + +```java +USER( + Set.of( + ADMIN_CREATE // Now USER can create admin resources + ) +), +``` + +### Available Permissions + +All permissions are defined in `Permission.java`: + +```java +public enum Permission { + ADMIN_CREATE("admin:create"), + ADMIN_READ("admin:read"), + ADMIN_UPDATE("admin:update"), + ADMIN_DELETE("admin:delete"), + MANAGER_CREATE("management:create"), + MANAGER_READ("management:read"), + MANAGER_UPDATE("management:update"), + MANAGER_DELETE("management:delete"); +} +``` + +### Security Configuration + +⚠️ **Important:** The `SecurityConfiguration.java` must use `.getPermission()` not `.name()`: + +```java +// ✅ CORRECT: +.requestMatchers(POST, "/api/v1/management/**") + .hasAnyAuthority(ADMIN_CREATE.getPermission(), MANAGER_CREATE.getPermission()) + +// OR use strings directly: +.requestMatchers(POST, "/api/v1/management/**") + .hasAnyAuthority("admin:create", "management:create") + +// ❌ WRONG (will not work): +.requestMatchers(POST, "/api/v1/management/**") + .hasAnyAuthority(ADMIN_CREATE.name(), MANAGER_CREATE.name()) +``` + +### Role vs Authority Checks + +**Role-based checks** (checks for `ROLE_*`): +```java +@PreAuthorize("hasRole('ADMIN')") // Only users with ADMIN role +``` + +**Permission-based checks** (checks for specific permissions): +```java +@PreAuthorize("hasAuthority('admin:create')") // Anyone with admin:create permission +``` + +### Best Practices + +1. **Use permissions for fine-grained control**: Check for specific permissions like `admin:read` instead of roles +2. **Keep USER role minimal**: Only add permissions if absolutely necessary +3. **ADMIN inherits MANAGER permissions**: This allows admins to access management endpoints +4. **Restart required**: Changes to `Role.java` require application restart +5. **Test after changes**: Always test permission changes with different user roles + +### Example Use Cases + +**Scenario 1: Give USER read-only access to management** +```java +USER(Set.of(MANAGER_READ)) +``` + +**Scenario 2: Create a custom limited role** +```java +READONLY_ADMIN( + Set.of( + ADMIN_READ, + MANAGER_READ + ) +) +``` + +**Scenario 3: Remove permissions from ADMIN** +```java +ADMIN( + Set.of( + ADMIN_CREATE, + ADMIN_READ, + // Removed: ADMIN_UPDATE, ADMIN_DELETE + MANAGER_CREATE, + MANAGER_READ, + MANAGER_UPDATE, + MANAGER_DELETE + ) +) +``` + ### Password Security - Passwords are hashed using BCrypt with strength 10 diff --git a/http/change-password.http b/http/change-password.http deleted file mode 100644 index 66f47d2e..00000000 --- a/http/change-password.http +++ /dev/null @@ -1,45 +0,0 @@ -### Register User -POST http://localhost:8080/api/v1/auth/register -Content-Type: application/json - -{ - "firstname": "Ali", - "lastname": "Bouali", - "email": "alibou@mail.com", - "password": "password", - "role": "ADMIN" -} - -> {% client.global.set("auth-token", response.body.access_token); %} - -### Query the Demo endpoint -GET http://localhost:8080/api/v1/demo-controller -Authorization: Bearer {{auth-token}} - - -### Change the password -PATCH http://localhost:8080/api/v1/users -Content-Type: application/json -Authorization: Bearer {{auth-token}} - -{ - "currentPassword": "password", - "newPassword": "newPassword", - "confirmationPassword": "newPassword" -} - -### Login again and update the token -POST http://localhost:8080/api/v1/auth/authenticate -Content-Type: application/json - -{ - "email": "alibou@mail.com", - "password": "newPassword" -} - -> {% client.global.set("new-auth-token", response.body.access_token); %} - - -### Query the Demo endpoint after password change -GET http://localhost:8080/api/v1/demo-controller -Authorization: Bearer {{new-auth-token}} diff --git a/http/http-test.http b/http/http-test.http index 918cb2e4..7454b838 100644 --- a/http/http-test.http +++ b/http/http-test.http @@ -1,17 +1,18 @@ ### Register User POST http://localhost:8080/api/v1/auth/register Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbkBtYWlsLmNvbSIsImlhdCI6MTc2MDk1OTAxMiwiZXhwIjoxNzYxMDQ1NDEyfQ.XnnTOk-XOyPXG0oKXJGJXqKhfV617HF9dkjgk7J2wq0 { - "firstname": "Ali", - "lastname": "Bouali", - "email": "alibou21@mail.com", - "password": "password", + "firstname": "Oleksii", + "lastname": "Morenets", + "email": "omore@mail.com", + "password": "password$123", "role": "ADMIN" } -> {% client.global.set("auth-token", response.body.access_token); %} +> {% client.global.set("access-token", response.body.access_token); %} ### Query the Demo endpoint -GET http://localhost:8080/api/v1/demo-controller -Authorization: Bearer {{auth-token}} +GET http://localhost:8080/api/v1/demo +Authorization: Bearer {{access-token}} diff --git a/http/jpa-auditing.http b/http/jpa-auditing.http deleted file mode 100644 index a8245bb8..00000000 --- a/http/jpa-auditing.http +++ /dev/null @@ -1,44 +0,0 @@ -### Register User -POST http://localhost:8080/api/v1/auth/register -Content-Type: application/json - -{ - "firstname": "Ali", - "lastname": "Bouali", - "email": "alibou@mail.com", - "password": "password", - "role": "ADMIN" -} - -> {% client.global.set("auth-token", response.body.access_token); %} - - -###Create a new book -POST http://localhost:8080/api/v1/books -Authorization: Bearer {{auth-token}} -Content-Type: application/json - -{ - "author": "Alibou", - "isbn": "12345" -} - -### Query Books -GET http://localhost:8080/api/v1/books -Authorization: Bearer {{auth-token}} - -### Update one book -POST http://localhost:8080/api/v1/books -Authorization: Bearer {{auth-token}} -Content-Type: application/json - -{ - "id": 1, - "author": "Alibou 2", - "isbn": "12345" -} - - -### Query the Books one more time -GET http://localhost:8080/api/v1/books -Authorization: Bearer {{auth-token}} diff --git a/src/main/java/com/alibou/security/auth/AuthenticationController.java b/src/main/java/com/alibou/security/auth/AuthenticationController.java deleted file mode 100644 index 7c1340ff..00000000 --- a/src/main/java/com/alibou/security/auth/AuthenticationController.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.alibou.security.auth; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.io.IOException; - -@RestController -@RequestMapping("/api/v1/auth") -@RequiredArgsConstructor -public class AuthenticationController { - - private final AuthenticationService authenticationService; - - @PostMapping("/register") - @PreAuthorize("hasRole('ADMIN')") - public ResponseEntity register(@RequestBody RegisterRequest request) { - return ResponseEntity.ok(authenticationService.register(request)); - } - - @PostMapping("/authenticate") - public ResponseEntity authenticate(@RequestBody AuthenticationRequest request) { - return ResponseEntity.ok(authenticationService.authenticate(request)); - } - - @PostMapping("/refresh-token") - public ResponseEntity refreshToken( - HttpServletRequest request, - HttpServletResponse response - ) throws IOException { - return ResponseEntity.ok(authenticationService.refreshToken(request, response)); - } -} diff --git a/src/main/java/com/alibou/security/book/BookController.java b/src/main/java/com/alibou/security/book/BookController.java deleted file mode 100644 index 479fec02..00000000 --- a/src/main/java/com/alibou/security/book/BookController.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.alibou.security.book; - -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequestMapping("/api/v1/books") -@RequiredArgsConstructor -public class BookController { - - private final BookService service; - - @PostMapping - public ResponseEntity save(@RequestBody BookRequest request) { - service.save(request); - - return ResponseEntity.accepted().build(); - } - - @GetMapping - public ResponseEntity> findAllBooks() { - return ResponseEntity.ok(service.findAll()); - } -} diff --git a/src/main/java/com/alibou/security/demo/AdminController.java b/src/main/java/com/alibou/security/demo/AdminController.java deleted file mode 100644 index 410bb0f3..00000000 --- a/src/main/java/com/alibou/security/demo/AdminController.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.alibou.security.demo; - -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/v1/admin") -@PreAuthorize("hasRole('ADMIN')") -@Tag(name = "Admin") -public class AdminController { - - @GetMapping - @PreAuthorize("hasAuthority('admin:read')") - public String get() { - return "GET:: admin controller"; - } - - @PostMapping - @PreAuthorize("hasAuthority('admin:create')") - public String post() { - return "POST:: admin controller"; - } - - @PutMapping - @PreAuthorize("hasAuthority('admin:update')") - public String put() { - return "PUT:: admin controller"; - } - - @DeleteMapping - @PreAuthorize("hasAuthority('admin:delete')") - public String delete() { - return "DELETE:: admin controller"; - } -} diff --git a/src/main/java/com/alibou/security/SecurityApplication.java b/src/main/java/com/omore/security/SecurityApplication.java similarity index 90% rename from src/main/java/com/alibou/security/SecurityApplication.java rename to src/main/java/com/omore/security/SecurityApplication.java index 3796d077..fcef76c0 100644 --- a/src/main/java/com/alibou/security/SecurityApplication.java +++ b/src/main/java/com/omore/security/SecurityApplication.java @@ -1,14 +1,14 @@ -package com.alibou.security; +package com.omore.security; -import com.alibou.security.auth.AuthenticationService; -import com.alibou.security.auth.RegisterRequest; +import com.omore.security.auth.AuthenticationService; +import com.omore.security.auth.RegisterRequest; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -import static com.alibou.security.user.Role.*; +import static com.omore.security.user.Role.*; @SpringBootApplication @EnableJpaAuditing(auditorAwareRef = "auditorAware") diff --git a/src/main/java/com/alibou/security/auditing/ApplicationAuditAware.java b/src/main/java/com/omore/security/auditing/ApplicationAuditAware.java similarity index 92% rename from src/main/java/com/alibou/security/auditing/ApplicationAuditAware.java rename to src/main/java/com/omore/security/auditing/ApplicationAuditAware.java index 29b2c14e..9ec57567 100644 --- a/src/main/java/com/alibou/security/auditing/ApplicationAuditAware.java +++ b/src/main/java/com/omore/security/auditing/ApplicationAuditAware.java @@ -1,6 +1,6 @@ -package com.alibou.security.auditing; +package com.omore.security.auditing; -import com.alibou.security.user.User; +import com.omore.security.user.User; import org.springframework.data.domain.AuditorAware; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; diff --git a/src/main/java/com/omore/security/auth/AuthenticationController.java b/src/main/java/com/omore/security/auth/AuthenticationController.java new file mode 100644 index 00000000..3e2b42e2 --- /dev/null +++ b/src/main/java/com/omore/security/auth/AuthenticationController.java @@ -0,0 +1,84 @@ +package com.omore.security.auth; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +@Tag(name = "Authentication") +public class AuthenticationController { + + private final AuthenticationService authenticationService; + + @Operation( + summary = "Register new user (Admin)", + description = "Permissions: `ADMIN_ROLE`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @PostMapping("/register") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity register(@RequestBody RegisterRequest request) { + return ResponseEntity.ok(authenticationService.register(request)); + } + + @Operation( + summary = "Authentication", + description = "Permissions: `any`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + } + ) + @PostMapping("/authenticate") + public ResponseEntity authenticate(@RequestBody AuthenticationRequest request) { + return ResponseEntity.ok(authenticationService.authenticate(request)); + } + + @Operation( + summary = "Refresh token", + description = "Permissions: `any`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Invalid/missing Token", + responseCode = "400" + ), + } + ) + @PostMapping("/refresh-token") + public ResponseEntity refreshToken(HttpServletRequest request) { + return ResponseEntity.ok(authenticationService.refreshToken(request)); + } +} diff --git a/src/main/java/com/alibou/security/auth/AuthenticationRequest.java b/src/main/java/com/omore/security/auth/AuthenticationRequest.java similarity index 88% rename from src/main/java/com/alibou/security/auth/AuthenticationRequest.java rename to src/main/java/com/omore/security/auth/AuthenticationRequest.java index d061e496..d97dceb1 100644 --- a/src/main/java/com/alibou/security/auth/AuthenticationRequest.java +++ b/src/main/java/com/omore/security/auth/AuthenticationRequest.java @@ -1,4 +1,4 @@ -package com.alibou.security.auth; +package com.omore.security.auth; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/java/com/alibou/security/auth/AuthenticationResponse.java b/src/main/java/com/omore/security/auth/AuthenticationResponse.java similarity index 92% rename from src/main/java/com/alibou/security/auth/AuthenticationResponse.java rename to src/main/java/com/omore/security/auth/AuthenticationResponse.java index 274f3109..b2135e03 100644 --- a/src/main/java/com/alibou/security/auth/AuthenticationResponse.java +++ b/src/main/java/com/omore/security/auth/AuthenticationResponse.java @@ -1,4 +1,4 @@ -package com.alibou.security.auth; +package com.omore.security.auth; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; diff --git a/src/main/java/com/alibou/security/auth/AuthenticationService.java b/src/main/java/com/omore/security/auth/AuthenticationService.java similarity index 90% rename from src/main/java/com/alibou/security/auth/AuthenticationService.java rename to src/main/java/com/omore/security/auth/AuthenticationService.java index 185ee596..a98d7322 100644 --- a/src/main/java/com/alibou/security/auth/AuthenticationService.java +++ b/src/main/java/com/omore/security/auth/AuthenticationService.java @@ -1,13 +1,12 @@ -package com.alibou.security.auth; - -import com.alibou.security.config.JwtService; -import com.alibou.security.token.Token; -import com.alibou.security.token.TokenRepository; -import com.alibou.security.token.TokenType; -import com.alibou.security.user.User; -import com.alibou.security.user.UserRepository; +package com.omore.security.auth; + +import com.omore.security.config.JwtService; +import com.omore.security.token.Token; +import com.omore.security.token.TokenRepository; +import com.omore.security.token.TokenType; +import com.omore.security.user.User; +import com.omore.security.user.UserRepository; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.AuthenticationManager; @@ -15,8 +14,6 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import java.io.IOException; - @Service @RequiredArgsConstructor public class AuthenticationService { @@ -65,7 +62,7 @@ public AuthenticationResponse authenticate(AuthenticationRequest request) { .build(); } - public AuthenticationResponse refreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException { + public AuthenticationResponse refreshToken(HttpServletRequest request) { final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); if (authHeader == null || !authHeader.startsWith("Bearer ")) { diff --git a/src/main/java/com/alibou/security/auth/RegisterRequest.java b/src/main/java/com/omore/security/auth/RegisterRequest.java similarity index 82% rename from src/main/java/com/alibou/security/auth/RegisterRequest.java rename to src/main/java/com/omore/security/auth/RegisterRequest.java index c8d0e97c..66e3e887 100644 --- a/src/main/java/com/alibou/security/auth/RegisterRequest.java +++ b/src/main/java/com/omore/security/auth/RegisterRequest.java @@ -1,6 +1,6 @@ -package com.alibou.security.auth; +package com.omore.security.auth; -import com.alibou.security.user.Role; +import com.omore.security.user.Role; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; diff --git a/src/main/java/com/alibou/security/book/Book.java b/src/main/java/com/omore/security/book/Book.java similarity index 97% rename from src/main/java/com/alibou/security/book/Book.java rename to src/main/java/com/omore/security/book/Book.java index 818ef2f4..2b5b5dfc 100644 --- a/src/main/java/com/alibou/security/book/Book.java +++ b/src/main/java/com/omore/security/book/Book.java @@ -1,4 +1,4 @@ -package com.alibou.security.book; +package com.omore.security.book; import jakarta.persistence.*; import lombok.AllArgsConstructor; diff --git a/src/main/java/com/omore/security/book/BookController.java b/src/main/java/com/omore/security/book/BookController.java new file mode 100644 index 00000000..7930650f --- /dev/null +++ b/src/main/java/com/omore/security/book/BookController.java @@ -0,0 +1,67 @@ +package com.omore.security.book; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/books") +@RequiredArgsConstructor +@Tag(name = "Books") +public class BookController { + + private final BookService service; + + @Operation( + summary = "POST endpoint for any authenticated user", + description = "Permissions: `any authenticated`", + responses = { + @ApiResponse( + description = "Accepted", + responseCode = "202" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @PostMapping + public ResponseEntity save(@RequestBody BookRequest request) { + service.save(request); + + return ResponseEntity.accepted().build(); + } + + @Operation( + summary = "GET endpoint for any authenticated user", + description = "Permissions: `any authenticated`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @GetMapping + public ResponseEntity> findAllBooks() { + return ResponseEntity.ok(service.findAll()); + } +} diff --git a/src/main/java/com/alibou/security/book/BookRepository.java b/src/main/java/com/omore/security/book/BookRepository.java similarity index 80% rename from src/main/java/com/alibou/security/book/BookRepository.java rename to src/main/java/com/omore/security/book/BookRepository.java index 21ca467c..3c45fa82 100644 --- a/src/main/java/com/alibou/security/book/BookRepository.java +++ b/src/main/java/com/omore/security/book/BookRepository.java @@ -1,4 +1,4 @@ -package com.alibou.security.book; +package com.omore.security.book; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/alibou/security/book/BookRequest.java b/src/main/java/com/omore/security/book/BookRequest.java similarity index 58% rename from src/main/java/com/alibou/security/book/BookRequest.java rename to src/main/java/com/omore/security/book/BookRequest.java index dcf6765b..6202fc50 100644 --- a/src/main/java/com/alibou/security/book/BookRequest.java +++ b/src/main/java/com/omore/security/book/BookRequest.java @@ -1,12 +1,16 @@ -package com.alibou.security.book; +package com.omore.security.book; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @Builder +@NoArgsConstructor +@AllArgsConstructor public class BookRequest { private Integer id; diff --git a/src/main/java/com/alibou/security/book/BookService.java b/src/main/java/com/omore/security/book/BookService.java similarity index 94% rename from src/main/java/com/alibou/security/book/BookService.java rename to src/main/java/com/omore/security/book/BookService.java index c09ded8b..62374ac0 100644 --- a/src/main/java/com/alibou/security/book/BookService.java +++ b/src/main/java/com/omore/security/book/BookService.java @@ -1,4 +1,4 @@ -package com.alibou.security.book; +package com.omore.security.book; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/alibou/security/config/ApplicationConfig.java b/src/main/java/com/omore/security/config/ApplicationConfig.java similarity index 69% rename from src/main/java/com/alibou/security/config/ApplicationConfig.java rename to src/main/java/com/omore/security/config/ApplicationConfig.java index 85f799a3..21927155 100644 --- a/src/main/java/com/alibou/security/config/ApplicationConfig.java +++ b/src/main/java/com/omore/security/config/ApplicationConfig.java @@ -1,14 +1,12 @@ -package com.alibou.security.config; +package com.omore.security.config; -import com.alibou.security.auditing.ApplicationAuditAware; -import com.alibou.security.user.UserRepository; +import com.omore.security.auditing.ApplicationAuditAware; +import com.omore.security.user.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.domain.AuditorAware; import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.AuthenticationProvider; -import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -27,15 +25,6 @@ public UserDetailsService userDetailsService() { .orElseThrow(() -> new UsernameNotFoundException("User not found")); } - // @Bean - public AuthenticationProvider authenticationProvider() { - DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); - authProvider.setUserDetailsService(userDetailsService()); - authProvider.setPasswordEncoder(passwordEncoder()); - - return authProvider; - } - @Bean public AuditorAware auditorAware() { return new ApplicationAuditAware(); diff --git a/src/main/java/com/omore/security/config/JacksonConfig.java b/src/main/java/com/omore/security/config/JacksonConfig.java new file mode 100644 index 00000000..775472f2 --- /dev/null +++ b/src/main/java/com/omore/security/config/JacksonConfig.java @@ -0,0 +1,20 @@ +package com.omore.security.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper; + } +} + diff --git a/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java b/src/main/java/com/omore/security/config/JwtAuthenticationFilter.java similarity index 97% rename from src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java rename to src/main/java/com/omore/security/config/JwtAuthenticationFilter.java index dcc86be6..577e48a9 100644 --- a/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java +++ b/src/main/java/com/omore/security/config/JwtAuthenticationFilter.java @@ -1,6 +1,6 @@ -package com.alibou.security.config; +package com.omore.security.config; -import com.alibou.security.token.TokenRepository; +import com.omore.security.token.TokenRepository; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; diff --git a/src/main/java/com/alibou/security/config/JwtService.java b/src/main/java/com/omore/security/config/JwtService.java similarity index 98% rename from src/main/java/com/alibou/security/config/JwtService.java rename to src/main/java/com/omore/security/config/JwtService.java index ce190f63..2ad84c50 100644 --- a/src/main/java/com/alibou/security/config/JwtService.java +++ b/src/main/java/com/omore/security/config/JwtService.java @@ -1,4 +1,4 @@ -package com.alibou.security.config; +package com.omore.security.config; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; diff --git a/src/main/java/com/alibou/security/config/LogoutService.java b/src/main/java/com/omore/security/config/LogoutService.java similarity index 93% rename from src/main/java/com/alibou/security/config/LogoutService.java rename to src/main/java/com/omore/security/config/LogoutService.java index 54118874..a3ff9351 100644 --- a/src/main/java/com/alibou/security/config/LogoutService.java +++ b/src/main/java/com/omore/security/config/LogoutService.java @@ -1,6 +1,6 @@ -package com.alibou.security.config; +package com.omore.security.config; -import com.alibou.security.token.TokenRepository; +import com.omore.security.token.TokenRepository; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/alibou/security/config/OpenApiConfig.java b/src/main/java/com/omore/security/config/OpenApiConfig.java similarity index 97% rename from src/main/java/com/alibou/security/config/OpenApiConfig.java rename to src/main/java/com/omore/security/config/OpenApiConfig.java index a6ea92cb..de2c27a2 100644 --- a/src/main/java/com/alibou/security/config/OpenApiConfig.java +++ b/src/main/java/com/omore/security/config/OpenApiConfig.java @@ -1,4 +1,4 @@ -package com.alibou.security.config; +package com.omore.security.config; import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; diff --git a/src/main/java/com/alibou/security/config/SecurityConfiguration.java b/src/main/java/com/omore/security/config/SecurityConfiguration.java similarity index 67% rename from src/main/java/com/alibou/security/config/SecurityConfiguration.java rename to src/main/java/com/omore/security/config/SecurityConfiguration.java index 5777a190..f50852ab 100644 --- a/src/main/java/com/alibou/security/config/SecurityConfiguration.java +++ b/src/main/java/com/omore/security/config/SecurityConfiguration.java @@ -1,5 +1,6 @@ -package com.alibou.security.config; +package com.omore.security.config; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -12,16 +13,18 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutHandler; -import static com.alibou.security.user.Permission.*; -import static com.alibou.security.user.Role.ADMIN; -import static com.alibou.security.user.Role.MANAGER; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +import static com.omore.security.user.Permission.*; import static org.springframework.http.HttpMethod.*; import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; @Configuration @EnableWebSecurity -@RequiredArgsConstructor @EnableMethodSecurity +@RequiredArgsConstructor public class SecurityConfiguration { private static final String[] WHITE_LIST_URL = { @@ -34,6 +37,7 @@ public class SecurityConfiguration { private final JwtAuthenticationFilter jwtAuthFilter; private final LogoutHandler logoutHandler; + private final ObjectMapper objectMapper; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -42,11 +46,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(req -> req .requestMatchers(WHITE_LIST_URL).permitAll() - .requestMatchers("/api/v1/management/**").hasAnyRole(ADMIN.name(), MANAGER.name()) - .requestMatchers(GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.name(), MANAGER_READ.name()) - .requestMatchers(POST, "/api/v1/management/**").hasAnyAuthority(ADMIN_CREATE.name(), MANAGER_CREATE.name()) - .requestMatchers(PUT, "/api/v1/management/**").hasAnyAuthority(ADMIN_UPDATE.name(), MANAGER_UPDATE.name()) - .requestMatchers(DELETE, "/api/v1/management/**").hasAnyAuthority(ADMIN_DELETE.name(), MANAGER_DELETE.name()) + .requestMatchers(POST, "/api/v1/management/**").hasAnyAuthority(ADMIN_CREATE.getPermission(), MANAGER_CREATE.getPermission()) + .requestMatchers(GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.getPermission(), MANAGER_READ.getPermission()) + .requestMatchers(PUT, "/api/v1/management/**").hasAnyAuthority(ADMIN_UPDATE.getPermission(), MANAGER_UPDATE.getPermission()) + .requestMatchers(DELETE, "/api/v1/management/**").hasAnyAuthority(ADMIN_DELETE.getPermission(), MANAGER_DELETE.getPermission()) .anyRequest().authenticated() ) .sessionManagement(session -> session.sessionCreationPolicy(STATELESS)) @@ -55,12 +58,26 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authenticationEntryPoint((request, response, authException) -> { response.setStatus(401); response.setContentType("application/json"); - response.getWriter().write("{\"error\":\"Unauthorized\",\"message\":\"" + authException.getMessage() + "\"}"); + + Map errorResponse = new HashMap<>(); + errorResponse.put("timestamp", LocalDateTime.now()); + errorResponse.put("status", 401); + errorResponse.put("error", "Unauthorized"); + errorResponse.put("message", authException.getMessage()); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); }) .accessDeniedHandler((request, response, accessDeniedException) -> { response.setStatus(403); response.setContentType("application/json"); - response.getWriter().write("{\"error\":\"Forbidden\",\"message\":\"Access denied\"}"); + + Map errorResponse = new HashMap<>(); + errorResponse.put("timestamp", LocalDateTime.now()); + errorResponse.put("status", 403); + errorResponse.put("error", "Forbidden"); + errorResponse.put("message", "Access denied"); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); }) ) .logout(logout -> diff --git a/src/main/java/com/omore/security/demo/AdminController.java b/src/main/java/com/omore/security/demo/AdminController.java new file mode 100644 index 00000000..2f7f87b7 --- /dev/null +++ b/src/main/java/com/omore/security/demo/AdminController.java @@ -0,0 +1,110 @@ +package com.omore.security.demo; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin") +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "Admin") +public class AdminController { + + @Operation( + summary = "POST endpoint for admin", + description = "Permissions: `admin:read`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @PostMapping + @PreAuthorize("hasAuthority('admin:create')") + public String post() { + return "POST:: admin controller"; + } + + @Operation( + summary = "GET endpoint for admin", + description = "Permissions: `admin:read`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @GetMapping + @PreAuthorize("hasAuthority('admin:read')") + public String get() { + return "GET:: admin controller"; + } + + @Operation( + summary = "PUT endpoint for admin", + description = "Permissions: `admin:read`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @PutMapping + @PreAuthorize("hasAuthority('admin:update')") + public String put() { + return "PUT:: admin controller"; + } + + @Operation( + summary = "DELETE endpoint for admin", + description = "Permissions: `admin:read`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @DeleteMapping + @PreAuthorize("hasAuthority('admin:delete')") + public String delete() { + return "DELETE:: admin controller"; + } +} diff --git a/src/main/java/com/alibou/security/demo/DemoController.java b/src/main/java/com/omore/security/demo/DemoController.java similarity index 74% rename from src/main/java/com/alibou/security/demo/DemoController.java rename to src/main/java/com/omore/security/demo/DemoController.java index 84ac9d41..aa8a5814 100644 --- a/src/main/java/com/alibou/security/demo/DemoController.java +++ b/src/main/java/com/omore/security/demo/DemoController.java @@ -1,14 +1,14 @@ -package com.alibou.security.demo; +package com.omore.security.demo; -import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/v1/demo-controller") -@Hidden +@RequestMapping("/api/v1/demo") +@Tag(name = "Demo Controller") public class DemoController { @GetMapping diff --git a/src/main/java/com/alibou/security/demo/ManagementController.java b/src/main/java/com/omore/security/demo/ManagementController.java similarity index 84% rename from src/main/java/com/alibou/security/demo/ManagementController.java rename to src/main/java/com/omore/security/demo/ManagementController.java index 3d17e470..13f95942 100644 --- a/src/main/java/com/alibou/security/demo/ManagementController.java +++ b/src/main/java/com/omore/security/demo/ManagementController.java @@ -1,4 +1,4 @@ -package com.alibou.security.demo; +package com.omore.security.demo; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -11,8 +11,8 @@ public class ManagementController { @Operation( - summary = "Get endpoint for manager", - description = "Role `MANAGER` required", + summary = "POST endpoint for manager", + description = "Permissions: `manager:read` or `admin:read`", responses = { @ApiResponse( description = "Success", @@ -28,14 +28,14 @@ public class ManagementController { ) } ) - @GetMapping - public String get() { - return "GET:: management controller"; + @PostMapping + public String post() { + return "POST:: management controller"; } @Operation( - summary = "Post endpoint for manager", - description = "Role `MANAGER` required", + summary = "GET endpoint for manager", + description = "Permissions: `manager:read` or `admin:read`", responses = { @ApiResponse( description = "Success", @@ -51,14 +51,14 @@ public String get() { ) } ) - @PostMapping - public String post() { - return "POST:: management controller"; + @GetMapping + public String get() { + return "GET:: management controller"; } @Operation( - summary = "Put endpoint for manager", - description = "Role `MANAGER` required", + summary = "PUT endpoint for manager", + description = "Permissions: `manager:read` or `admin:read`", responses = { @ApiResponse( description = "Success", @@ -80,8 +80,8 @@ public String put() { } @Operation( - summary = "Delete endpoint for manager", - description = "Role `MANAGER` required", + summary = "DELETE endpoint for manager", + description = "Permissions: `manager:read` or `admin:read`", responses = { @ApiResponse( description = "Success", diff --git a/src/main/java/com/alibou/security/exception/GlobalExceptionHandler.java b/src/main/java/com/omore/security/exception/GlobalExceptionHandler.java similarity index 94% rename from src/main/java/com/alibou/security/exception/GlobalExceptionHandler.java rename to src/main/java/com/omore/security/exception/GlobalExceptionHandler.java index 6ae6c5a1..e2893bdc 100644 --- a/src/main/java/com/alibou/security/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/omore/security/exception/GlobalExceptionHandler.java @@ -1,4 +1,4 @@ -package com.alibou.security.exception; +package com.omore.security.exception; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -33,7 +33,7 @@ public ResponseEntity> handleBadCredentialsException(BadCred errorResponse.put("timestamp", LocalDateTime.now()); errorResponse.put("status", HttpStatus.UNAUTHORIZED.value()); errorResponse.put("error", "Unauthorized"); - errorResponse.put("message", "Invalid credentials"); + errorResponse.put("message", ex.getMessage()); return ResponseEntity .status(HttpStatus.UNAUTHORIZED) @@ -46,7 +46,7 @@ public ResponseEntity> handleAccessDeniedException(AccessDen errorResponse.put("timestamp", LocalDateTime.now()); errorResponse.put("status", HttpStatus.FORBIDDEN.value()); errorResponse.put("error", "Forbidden"); - errorResponse.put("message", "Access denied"); + errorResponse.put("message", ex.getMessage()); return ResponseEntity .status(HttpStatus.FORBIDDEN) diff --git a/src/main/java/com/alibou/security/token/Token.java b/src/main/java/com/omore/security/token/Token.java similarity index 88% rename from src/main/java/com/alibou/security/token/Token.java rename to src/main/java/com/omore/security/token/Token.java index 85f46522..fbf5ca4d 100644 --- a/src/main/java/com/alibou/security/token/Token.java +++ b/src/main/java/com/omore/security/token/Token.java @@ -1,6 +1,6 @@ -package com.alibou.security.token; +package com.omore.security.token; -import com.alibou.security.user.User; +import com.omore.security.user.User; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/java/com/alibou/security/token/TokenRepository.java b/src/main/java/com/omore/security/token/TokenRepository.java similarity index 93% rename from src/main/java/com/alibou/security/token/TokenRepository.java rename to src/main/java/com/omore/security/token/TokenRepository.java index 58ed1852..3100db3d 100644 --- a/src/main/java/com/alibou/security/token/TokenRepository.java +++ b/src/main/java/com/omore/security/token/TokenRepository.java @@ -1,4 +1,4 @@ -package com.alibou.security.token; +package com.omore.security.token; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; diff --git a/src/main/java/com/alibou/security/token/TokenType.java b/src/main/java/com/omore/security/token/TokenType.java similarity index 52% rename from src/main/java/com/alibou/security/token/TokenType.java rename to src/main/java/com/omore/security/token/TokenType.java index 35137032..c9e899c1 100644 --- a/src/main/java/com/alibou/security/token/TokenType.java +++ b/src/main/java/com/omore/security/token/TokenType.java @@ -1,4 +1,4 @@ -package com.alibou.security.token; +package com.omore.security.token; public enum TokenType { BEARER diff --git a/src/main/java/com/alibou/security/user/ChangePasswordRequest.java b/src/main/java/com/omore/security/user/ChangePasswordRequest.java similarity index 63% rename from src/main/java/com/alibou/security/user/ChangePasswordRequest.java rename to src/main/java/com/omore/security/user/ChangePasswordRequest.java index 70bca36b..9d42fa5a 100644 --- a/src/main/java/com/alibou/security/user/ChangePasswordRequest.java +++ b/src/main/java/com/omore/security/user/ChangePasswordRequest.java @@ -1,12 +1,16 @@ -package com.alibou.security.user; +package com.omore.security.user; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @Builder +@NoArgsConstructor +@AllArgsConstructor public class ChangePasswordRequest { private String currentPassword; diff --git a/src/main/java/com/alibou/security/user/Permission.java b/src/main/java/com/omore/security/user/Permission.java similarity index 93% rename from src/main/java/com/alibou/security/user/Permission.java rename to src/main/java/com/omore/security/user/Permission.java index 230a6c1f..8cf838b2 100644 --- a/src/main/java/com/alibou/security/user/Permission.java +++ b/src/main/java/com/omore/security/user/Permission.java @@ -1,4 +1,4 @@ -package com.alibou.security.user; +package com.omore.security.user; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -7,13 +7,13 @@ @RequiredArgsConstructor public enum Permission { + ADMIN_CREATE("admin:create"), ADMIN_READ("admin:read"), ADMIN_UPDATE("admin:update"), - ADMIN_CREATE("admin:create"), ADMIN_DELETE("admin:delete"), + MANAGER_CREATE("management:create"), MANAGER_READ("management:read"), MANAGER_UPDATE("management:update"), - MANAGER_CREATE("management:create"), MANAGER_DELETE("management:delete"); private final String permission; diff --git a/src/main/java/com/alibou/security/user/Role.java b/src/main/java/com/omore/security/user/Role.java similarity index 67% rename from src/main/java/com/alibou/security/user/Role.java rename to src/main/java/com/omore/security/user/Role.java index 04220b4a..c95a712d 100644 --- a/src/main/java/com/alibou/security/user/Role.java +++ b/src/main/java/com/omore/security/user/Role.java @@ -1,50 +1,64 @@ -package com.alibou.security.user; +package com.omore.security.user; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.security.core.authority.SimpleGrantedAuthority; -import java.util.Collections; import java.util.List; import java.util.Set; import java.util.stream.Collectors; -import static com.alibou.security.user.Permission.*; +import static com.omore.security.user.Permission.*; @Getter @RequiredArgsConstructor public enum Role { - USER(Collections.emptySet()), + USER( + Set.of() + ), ADMIN( Set.of( + ADMIN_CREATE, ADMIN_READ, ADMIN_UPDATE, ADMIN_DELETE, - ADMIN_CREATE, + MANAGER_CREATE, MANAGER_READ, MANAGER_UPDATE, - MANAGER_DELETE, - MANAGER_CREATE + MANAGER_DELETE ) ), MANAGER( Set.of( + MANAGER_CREATE, MANAGER_READ, MANAGER_UPDATE, - MANAGER_DELETE, - MANAGER_CREATE + MANAGER_DELETE ) ); private final Set permissions; + /** + * Returns a list of authorities for this role. + * + * @return Example for MANAGER: + * [ + * "management:create", + * "management:read", + * "management:update", + * "management:delete", + * "ROLE_MANAGER" + * ] + */ public List getAuthorities() { var authorities = getPermissions() .stream() .map(permission -> new SimpleGrantedAuthority(permission.getPermission())) .collect(Collectors.toList()); authorities.add(new SimpleGrantedAuthority("ROLE_" + this.name())); + return authorities; } } diff --git a/src/main/java/com/alibou/security/user/User.java b/src/main/java/com/omore/security/user/User.java similarity index 93% rename from src/main/java/com/alibou/security/user/User.java rename to src/main/java/com/omore/security/user/User.java index 01c33f4b..de09a948 100644 --- a/src/main/java/com/alibou/security/user/User.java +++ b/src/main/java/com/omore/security/user/User.java @@ -1,6 +1,6 @@ -package com.alibou.security.user; +package com.omore.security.user; -import com.alibou.security.token.Token; +import com.omore.security.token.Token; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/java/com/alibou/security/user/UserController.java b/src/main/java/com/omore/security/user/UserController.java similarity index 84% rename from src/main/java/com/alibou/security/user/UserController.java rename to src/main/java/com/omore/security/user/UserController.java index 40e77655..640c5178 100644 --- a/src/main/java/com/alibou/security/user/UserController.java +++ b/src/main/java/com/omore/security/user/UserController.java @@ -1,5 +1,6 @@ -package com.alibou.security.user; +package com.omore.security.user; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PatchMapping; @@ -12,11 +13,12 @@ @RestController @RequestMapping("/api/v1/users") @RequiredArgsConstructor +@Tag(name = "Users") public class UserController { private final UserService userService; - @PatchMapping + @PatchMapping("/me/password") public ResponseEntity changePassword(@RequestBody ChangePasswordRequest request, Principal connectedUser) { userService.changePassword(request, connectedUser); diff --git a/src/main/java/com/alibou/security/user/UserRepository.java b/src/main/java/com/omore/security/user/UserRepository.java similarity index 86% rename from src/main/java/com/alibou/security/user/UserRepository.java rename to src/main/java/com/omore/security/user/UserRepository.java index 26df9d1d..acf1b2b6 100644 --- a/src/main/java/com/alibou/security/user/UserRepository.java +++ b/src/main/java/com/omore/security/user/UserRepository.java @@ -1,4 +1,4 @@ -package com.alibou.security.user; +package com.omore.security.user; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/alibou/security/user/UserService.java b/src/main/java/com/omore/security/user/UserService.java similarity index 97% rename from src/main/java/com/alibou/security/user/UserService.java rename to src/main/java/com/omore/security/user/UserService.java index 84d48762..0707471e 100644 --- a/src/main/java/com/alibou/security/user/UserService.java +++ b/src/main/java/com/omore/security/user/UserService.java @@ -1,4 +1,4 @@ -package com.alibou.security.user; +package com.omore.security.user; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; diff --git a/src/test/java/com/alibou/security/SecurityApplicationTests.java b/src/test/java/com/omore/security/SecurityApplicationTests.java similarity index 86% rename from src/test/java/com/alibou/security/SecurityApplicationTests.java rename to src/test/java/com/omore/security/SecurityApplicationTests.java index 570c8bcd..575baa89 100644 --- a/src/test/java/com/alibou/security/SecurityApplicationTests.java +++ b/src/test/java/com/omore/security/SecurityApplicationTests.java @@ -1,4 +1,4 @@ -package com.alibou.security; +package com.omore.security; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; From 949e8de204ccd27008289e4b4bcecd4d3a264f23 Mon Sep 17 00:00:00 2001 From: Oleksii Morenets Date: Mon, 20 Oct 2025 22:59:03 +0200 Subject: [PATCH 7/8] HandlerExceptionResolver was used in JwtAuthenticationFilter --- .../config/JwtAuthenticationFilter.java | 63 ++++++++++------ .../exception/GlobalExceptionHandler.java | 71 +++++++++++-------- 2 files changed, 82 insertions(+), 52 deletions(-) diff --git a/src/main/java/com/omore/security/config/JwtAuthenticationFilter.java b/src/main/java/com/omore/security/config/JwtAuthenticationFilter.java index 577e48a9..2d144d9b 100644 --- a/src/main/java/com/omore/security/config/JwtAuthenticationFilter.java +++ b/src/main/java/com/omore/security/config/JwtAuthenticationFilter.java @@ -1,11 +1,15 @@ package com.omore.security.config; import com.omore.security.token.TokenRepository; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.SignatureException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.lang.NonNull; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; @@ -14,16 +18,29 @@ import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.HandlerExceptionResolver; import java.io.IOException; @Component -@RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; private final UserDetailsService userDetailsService; private final TokenRepository tokenRepository; + private final HandlerExceptionResolver exceptionResolver; + + public JwtAuthenticationFilter( + JwtService jwtService, + UserDetailsService userDetailsService, + TokenRepository tokenRepository, + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver exceptionResolver + ) { + this.jwtService = jwtService; + this.userDetailsService = userDetailsService; + this.tokenRepository = tokenRepository; + this.exceptionResolver = exceptionResolver; + } @Override protected void doFilterInternal( @@ -45,31 +62,35 @@ protected void doFilterInternal( } final String authHeader = request.getHeader("Authorization"); - final String jwt; - final String userEmail; if (authHeader == null || !authHeader.startsWith("Bearer ")) { filterChain.doFilter(request, response); - return; } - jwt = authHeader.substring(7); - userEmail = jwtService.extractUsername(jwt); - if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { - UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail); - var isTokenValid = tokenRepository.findByToken(jwt) - .map(t -> !t.isExpired() && !t.isRevoked()) - .orElse(false); - if (jwtService.isTokenValid(jwt, userDetails) && isTokenValid) { - UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() - ); - authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authToken); + try { + final String jwt = authHeader.substring(7); + final String userEmail = jwtService.extractUsername(jwt); + + if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail); + var isTokenValid = tokenRepository.findByToken(jwt) + .map(t -> !t.isExpired() && !t.isRevoked()) + .orElse(false); + + if (jwtService.isTokenValid(jwt, userDetails) && isTokenValid) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } } + filterChain.doFilter(request, response); + } catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException | + SignatureException | IllegalArgumentException ex) { + exceptionResolver.resolveException(request, response, null, ex); } - filterChain.doFilter(request, response); } } diff --git a/src/main/java/com/omore/security/exception/GlobalExceptionHandler.java b/src/main/java/com/omore/security/exception/GlobalExceptionHandler.java index e2893bdc..7571a9a1 100644 --- a/src/main/java/com/omore/security/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/omore/security/exception/GlobalExceptionHandler.java @@ -1,5 +1,9 @@ package com.omore.security.exception; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.SignatureException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; @@ -14,55 +18,60 @@ @RestControllerAdvice public class GlobalExceptionHandler { + @ExceptionHandler(ExpiredJwtException.class) + public ResponseEntity> handleExpiredJwtException(ExpiredJwtException ex) { + return buildErrorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + + @ExceptionHandler(UnsupportedJwtException.class) + public ResponseEntity> handleUnsupportedJwtException(UnsupportedJwtException ex) { + return buildErrorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + + @ExceptionHandler(MalformedJwtException.class) + public ResponseEntity> handleMalformedJwtException(MalformedJwtException ex) { + return buildErrorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + + @ExceptionHandler(SignatureException.class) + public ResponseEntity> handleSignatureException(SignatureException ex) { + return buildErrorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException ex) { + return buildErrorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + @ExceptionHandler(IllegalStateException.class) public ResponseEntity> handleIllegalStateException(IllegalStateException ex) { - Map errorResponse = new HashMap<>(); - errorResponse.put("timestamp", LocalDateTime.now()); - errorResponse.put("status", HttpStatus.BAD_REQUEST.value()); - errorResponse.put("error", "Bad Request"); - errorResponse.put("message", ex.getMessage()); - - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(errorResponse); + return buildErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } @ExceptionHandler(BadCredentialsException.class) public ResponseEntity> handleBadCredentialsException(BadCredentialsException ex) { - Map errorResponse = new HashMap<>(); - errorResponse.put("timestamp", LocalDateTime.now()); - errorResponse.put("status", HttpStatus.UNAUTHORIZED.value()); - errorResponse.put("error", "Unauthorized"); - errorResponse.put("message", ex.getMessage()); - - return ResponseEntity - .status(HttpStatus.UNAUTHORIZED) - .body(errorResponse); + return buildErrorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); } @ExceptionHandler(AccessDeniedException.class) public ResponseEntity> handleAccessDeniedException(AccessDeniedException ex) { - Map errorResponse = new HashMap<>(); - errorResponse.put("timestamp", LocalDateTime.now()); - errorResponse.put("status", HttpStatus.FORBIDDEN.value()); - errorResponse.put("error", "Forbidden"); - errorResponse.put("message", ex.getMessage()); - - return ResponseEntity - .status(HttpStatus.FORBIDDEN) - .body(errorResponse); + return buildErrorResponse(HttpStatus.FORBIDDEN, ex.getMessage()); } @ExceptionHandler(Exception.class) public ResponseEntity> handleGeneralException(Exception ex) { + return buildErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); + } + + private ResponseEntity> buildErrorResponse(HttpStatus status, String message) { Map errorResponse = new HashMap<>(); errorResponse.put("timestamp", LocalDateTime.now()); - errorResponse.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); - errorResponse.put("error", "Internal Server Error"); - errorResponse.put("message", ex.getMessage()); + errorResponse.put("status", status.value()); + errorResponse.put("error", status.getReasonPhrase()); + errorResponse.put("message", message); return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(status) .body(errorResponse); } } From bbb7ed185cdfde085cff721e46b5e15aba60a062 Mon Sep 17 00:00:00 2001 From: Oleksii Morenets Date: Thu, 30 Oct 2025 10:33:14 +0100 Subject: [PATCH 8/8] minor layout fixes --- docker-compose.yml | 1 + src/main/java/com/omore/security/user/UserService.java | 1 + 2 files changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 0927d150..8f4c5513 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,5 @@ services: + postgres: container_name: postgres-sql image: postgres diff --git a/src/main/java/com/omore/security/user/UserService.java b/src/main/java/com/omore/security/user/UserService.java index 0707471e..0dff16fb 100644 --- a/src/main/java/com/omore/security/user/UserService.java +++ b/src/main/java/com/omore/security/user/UserService.java @@ -31,6 +31,7 @@ public void changePassword(ChangePasswordRequest request, Principal connectedUse // update the password user.setPassword(passwordEncoder.encode(request.getNewPassword())); + // save the new password repository.save(user); }