From 015dc9edc9d1eb1e51a2813f3b7c9a9ac0cec620 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 10:17:50 +0400 Subject: [PATCH 01/85] feat(dto): add ForgotPasswordRequest record Carries the email address to which a one-time password reset link will be sent. The email field is validated with @NotBlank and @Email so an invalid address is rejected at the HTTP layer before reaching the service, keeping the error response uniform with other endpoints. --- .../dto/auth/ForgotPasswordRequest.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/java/com/edgareldy/springboottutorial/dto/auth/ForgotPasswordRequest.java diff --git a/src/main/java/com/edgareldy/springboottutorial/dto/auth/ForgotPasswordRequest.java b/src/main/java/com/edgareldy/springboottutorial/dto/auth/ForgotPasswordRequest.java new file mode 100644 index 0000000..c9ece5e --- /dev/null +++ b/src/main/java/com/edgareldy/springboottutorial/dto/auth/ForgotPasswordRequest.java @@ -0,0 +1,20 @@ +package com.edgareldy.springboottutorial.dto.auth; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +/** + * Request body for the POST /auth/forgot-password endpoint. Carries the + * email address to which a one-time password reset link will be sent. + *

+ * Created edgar.muhamyangabo on 11/9/26 + * Author : edgar.muhamyangabo + * Date : 11/9/26 + * Project : spring-boot-tutorial + */ +public record ForgotPasswordRequest( + @NotBlank(message = "Email must not be blank") + @Email(message = "Email must be a valid email address") + String email +) { +} From 95a9afa0e1bae92b0f599fa72e485cc74bc21f7d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 10:18:02 +0400 Subject: [PATCH 02/85] feat(dto): add ResetPasswordRequest record Carries the new password and its confirmation for the reset-password endpoint. Token is intentionally absent: it travels as a @RequestParam in the URL (see AuthController) so the frontend can copy it directly from the reset-link query string without wrapping it in a JSON body. @PasswordsMatch and @ValidPassword mirror the constraints already on RegisterRequest.confirmPassword, ensuring the same complexity rules apply whether a user sets a password for the first time or resets it. toString() masks both password fields to prevent them from leaking into logs via LoggingAspect (same pattern as RegisterRequest). --- .../dto/auth/ResetPasswordRequest.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/main/java/com/edgareldy/springboottutorial/dto/auth/ResetPasswordRequest.java diff --git a/src/main/java/com/edgareldy/springboottutorial/dto/auth/ResetPasswordRequest.java b/src/main/java/com/edgareldy/springboottutorial/dto/auth/ResetPasswordRequest.java new file mode 100644 index 0000000..986ff6c --- /dev/null +++ b/src/main/java/com/edgareldy/springboottutorial/dto/auth/ResetPasswordRequest.java @@ -0,0 +1,33 @@ +package com.edgareldy.springboottutorial.dto.auth; + +import com.edgareldy.springboottutorial.validation.password.PasswordsMatch; +import com.edgareldy.springboottutorial.validation.password.ValidPassword; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; + +/** + * Request body for the POST /auth/reset-password endpoint. Carries the new + * password and its confirmation; the one-time reset token is passed as a + * query parameter so the frontend can forward it directly from the email + * link without re-embedding it in a JSON payload. + *

+ * Created edgar.muhamyangabo on 11/9/26 + * Author : edgar.muhamyangabo + * Date : 11/9/26 + * Project : spring-boot-tutorial + */ +@PasswordsMatch(passwordField = "newPassword", confirmPasswordField = "confirmPassword", + message = "Passwords must match") +public record ResetPasswordRequest( + @NotBlank(message = "New password must not be blank") + @ValidPassword + @JsonProperty("new_password") String newPassword, + + @NotBlank(message = "Password confirmation must not be blank") + @JsonProperty("confirm_password") String confirmPassword +) { + @Override + public String toString() { + return "ResetPasswordRequest[newPassword=***, confirmPassword=***]"; + } +} From e7c7231b8caff7bd9e815e9caaf6048506b18b39 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 10:18:12 +0400 Subject: [PATCH 03/85] feat(service): add UserService interface for token-driven user operations Defines the three lifecycle endpoints that depend on the token services introduced on this branch: activate (ActivationToken), forgotPassword and resetPassword (PasswordResetToken). Keeping these in a dedicated interface rather than mixing them into AuthService follows the same single-responsibility split already in place between AuthService and the individual token services. --- .../service/UserService.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/main/java/com/edgareldy/springboottutorial/service/UserService.java diff --git a/src/main/java/com/edgareldy/springboottutorial/service/UserService.java b/src/main/java/com/edgareldy/springboottutorial/service/UserService.java new file mode 100644 index 0000000..9be638f --- /dev/null +++ b/src/main/java/com/edgareldy/springboottutorial/service/UserService.java @@ -0,0 +1,22 @@ +package com.edgareldy.springboottutorial.service; + +import com.edgareldy.springboottutorial.dto.auth.UserResponse; + +/** + * Contract for token-driven user operations: account activation, password + * reset request, and password reset confirmation. Controllers and tests + * depend on this interface, never on its implementation directly. + *

+ * Created edgar.muhamyangabo on 11/9/26 + * Author : edgar.muhamyangabo + * Date : 11/9/26 + * Project : spring-boot-tutorial + */ +public interface UserService { + + UserResponse activate(String token); + + void forgotPassword(String email); + + void resetPassword(String token, String newPassword); +} From 7252e10a6c1a94f43a5a334d5e494caf7f6cd745 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 10:24:03 +0400 Subject: [PATCH 04/85] feat(entity): add enabled field to AppUser Accounts start disabled by default (@Builder.Default = false). AuthServiceImpl.register() will set it to false explicitly and send an activation email; UserServiceImpl.activate() flips it to true once the token is validated. --- .../com/edgareldy/springboottutorial/entity/user/AppUser.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/entity/user/AppUser.java b/src/main/java/com/edgareldy/springboottutorial/entity/user/AppUser.java index b29f58a..9a50657 100644 --- a/src/main/java/com/edgareldy/springboottutorial/entity/user/AppUser.java +++ b/src/main/java/com/edgareldy/springboottutorial/entity/user/AppUser.java @@ -55,4 +55,8 @@ public class AppUser { @Enumerated(EnumType.STRING) @Column(nullable = false) private Role role; + + @Builder.Default + @Column(nullable = false) + private boolean enabled = false; } From b1a3bd08a300bbd297db7f236417ad1da24d7018 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 10:24:10 +0400 Subject: [PATCH 05/85] feat(flyway): add enabled column to app_users (V10) Adds the boolean flag that controls whether a registered account can log in. Default FALSE so every new row starts disabled until the user clicks the activation link sent by email. --- .../resources/db/migration/V10__add_enabled_to_app_users.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/main/resources/db/migration/V10__add_enabled_to_app_users.sql diff --git a/src/main/resources/db/migration/V10__add_enabled_to_app_users.sql b/src/main/resources/db/migration/V10__add_enabled_to_app_users.sql new file mode 100644 index 0000000..9889a28 --- /dev/null +++ b/src/main/resources/db/migration/V10__add_enabled_to_app_users.sql @@ -0,0 +1,2 @@ +ALTER TABLE app_users + ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT FALSE; From dd296cb3dd7f42eca8f6f8a2041a670f935cbe9a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 10:24:28 +0400 Subject: [PATCH 06/85] refactor(service): send activation email on register in AuthServiceImpl Accounts are now created with enabled=false so they cannot log in until the user clicks the activation link. register() generates an ActivationToken and delegates the email to EmailService. This wires the token infrastructure from this branch into the registration flow, completing the activation lifecycle. --- .../service/impl/AuthServiceImpl.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java b/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java index faf3b52..718140f 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java @@ -11,8 +11,10 @@ import com.edgareldy.springboottutorial.mapper.AppUserMapper; import com.edgareldy.springboottutorial.repository.AppUserRepository; import com.edgareldy.springboottutorial.security.JwtService; +import com.edgareldy.springboottutorial.service.ActivationTokenService; import com.edgareldy.springboottutorial.service.AuthService; import com.edgareldy.springboottutorial.service.BlacklistedTokenService; +import com.edgareldy.springboottutorial.service.EmailService; import java.time.Instant; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.AuthenticationManager; @@ -43,6 +45,8 @@ public class AuthServiceImpl implements AuthService { private final AuthenticationManager authenticationManager; private final JwtService jwtService; private final BlacklistedTokenService blacklistedTokenService; + private final ActivationTokenService activationTokenService; + private final EmailService emailService; @Override @Transactional @@ -54,8 +58,12 @@ public UserResponse register(RegisterRequest request) { .email(request.email()) .password(passwordEncoder.encode(request.password())) .role(Role.USER) + .enabled(false) .build(); - return appUserMapper.toResponse(appUserRepository.save(appUser)); + AppUser saved = appUserRepository.save(appUser); + String token = activationTokenService.generate(saved); + emailService.sendActivationEmail(saved, token); + return appUserMapper.toResponse(saved); } @Override From 2bb1831549befd378c407402f830a63b106fd343 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 10:24:37 +0400 Subject: [PATCH 07/85] refactor(security): enforce enabled flag in UserDetailsServiceImpl Passes disabled(!appUser.isEnabled()) to the User builder so Spring Security's AuthenticationManager rejects login attempts on accounts that have not yet been activated via the email link. --- .../springboottutorial/security/UserDetailsServiceImpl.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/security/UserDetailsServiceImpl.java b/src/main/java/com/edgareldy/springboottutorial/security/UserDetailsServiceImpl.java index 1978592..fa17f22 100644 --- a/src/main/java/com/edgareldy/springboottutorial/security/UserDetailsServiceImpl.java +++ b/src/main/java/com/edgareldy/springboottutorial/security/UserDetailsServiceImpl.java @@ -33,6 +33,7 @@ public UserDetails loadUserByUsername(String username) { return User.withUsername(appUser.getUsername()) .password(appUser.getPassword()) .authorities("ROLE_" + appUser.getRole().name()) + .disabled(!appUser.isEnabled()) .build(); } } From 88f9154bef969211053fbf5f79585d70f1c4c4cb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 10:24:42 +0400 Subject: [PATCH 08/85] feat(service): add UserServiceImpl for account activation and password reset activate() validates the ActivationToken, flips enabled=true, and returns the updated user profile. forgotPassword() generates a PasswordResetToken and sends the reset email only if the email matches a known account (no information leakage on unknown emails). resetPassword() validates and consumes the PasswordResetToken, then re-encodes and saves the new password. --- .../service/impl/UserServiceImpl.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java diff --git a/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java b/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..fb4b336 --- /dev/null +++ b/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java @@ -0,0 +1,61 @@ +package com.edgareldy.springboottutorial.service.impl; + +import com.edgareldy.springboottutorial.dto.auth.UserResponse; +import com.edgareldy.springboottutorial.entity.user.AppUser; +import com.edgareldy.springboottutorial.mapper.AppUserMapper; +import com.edgareldy.springboottutorial.repository.AppUserRepository; +import com.edgareldy.springboottutorial.service.ActivationTokenService; +import com.edgareldy.springboottutorial.service.EmailService; +import com.edgareldy.springboottutorial.service.PasswordResetTokenService; +import com.edgareldy.springboottutorial.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Default {@link UserService} implementation handling account activation and + * password reset flows. Each flow delegates token creation/validation to the + * dedicated token services and email sending to {@link EmailService}. + *

+ * Created edgar.muhamyangabo on 11/9/26 + * Author : edgar.muhamyangabo + * Date : 11/9/26 + * Project : spring-boot-tutorial + */ +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private final AppUserRepository appUserRepository; + private final AppUserMapper appUserMapper; + private final ActivationTokenService activationTokenService; + private final PasswordResetTokenService passwordResetTokenService; + private final EmailService emailService; + private final PasswordEncoder passwordEncoder; + + @Override + @Transactional + public UserResponse activate(String token) { + AppUser user = activationTokenService.validate(token); + user.setEnabled(true); + return appUserMapper.toResponse(appUserRepository.save(user)); + } + + @Override + @Transactional + public void forgotPassword(String email) { + appUserRepository.findByEmailIgnoreCase(email).ifPresent(user -> { + String token = passwordResetTokenService.generate(user); + emailService.sendPasswordResetEmail(user, token); + }); + } + + @Override + @Transactional + public void resetPassword(String token, String newPassword) { + AppUser user = passwordResetTokenService.validateAndConsume(token); + user.setPassword(passwordEncoder.encode(newPassword)); + appUserRepository.save(user); + } +} From 71678c521e25243e530ade656edf14d63c3b157f Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 10:25:05 +0400 Subject: [PATCH 09/85] feat(controller): add activate, forgot-password, reset-password endpoints GET /auth/activate?token={token} - activates the account POST /auth/forgot-password - sends a reset link POST /auth/reset-password?token={...} - confirms the new password The reset token travels as a @RequestParam so the frontend can copy it straight from the email link URL without embedding it in a JSON body. ResetPasswordRequest carries the new password and its confirmation only; @PasswordsMatch validates both fields match and @ValidPassword enforces complexity rules. --- .../controller/AuthController.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/controller/AuthController.java b/src/main/java/com/edgareldy/springboottutorial/controller/AuthController.java index 040c7e0..1784277 100644 --- a/src/main/java/com/edgareldy/springboottutorial/controller/AuthController.java +++ b/src/main/java/com/edgareldy/springboottutorial/controller/AuthController.java @@ -1,11 +1,14 @@ package com.edgareldy.springboottutorial.controller; import com.edgareldy.springboottutorial.dto.auth.AuthResponse; +import com.edgareldy.springboottutorial.dto.auth.ForgotPasswordRequest; import com.edgareldy.springboottutorial.dto.auth.LoginRequest; import com.edgareldy.springboottutorial.dto.auth.RegisterRequest; +import com.edgareldy.springboottutorial.dto.auth.ResetPasswordRequest; import com.edgareldy.springboottutorial.dto.auth.UserResponse; import com.edgareldy.springboottutorial.dto.common.ApiResponse; import com.edgareldy.springboottutorial.service.AuthService; +import com.edgareldy.springboottutorial.service.UserService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -17,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** @@ -37,6 +41,7 @@ public class AuthController { private static final String BEARER_PREFIX = "Bearer "; private final AuthService authService; + private final UserService userService; @PostMapping("/register") public ResponseEntity> register(@Valid @RequestBody RegisterRequest request) { @@ -61,4 +66,23 @@ public ResponseEntity> logout( public ResponseEntity> me(@AuthenticationPrincipal UserDetails userDetails) { return ResponseEntity.ok(ApiResponse.success(authService.me(userDetails.getUsername()), "Profile retrieved successfully")); } + + @GetMapping("/activate") + public ResponseEntity> activate(@RequestParam String token) { + return ResponseEntity.ok(ApiResponse.success(userService.activate(token), "Account activated successfully")); + } + + @PostMapping("/forgot-password") + public ResponseEntity> forgotPassword(@Valid @RequestBody ForgotPasswordRequest request) { + userService.forgotPassword(request.email()); + return ResponseEntity.ok(ApiResponse.success(null, "If the email is registered a reset link has been sent")); + } + + @PostMapping("/reset-password") + public ResponseEntity> resetPassword( + @RequestParam String token, + @Valid @RequestBody ResetPasswordRequest request) { + userService.resetPassword(token, request.newPassword()); + return ResponseEntity.ok(ApiResponse.success(null, "Password reset successfully")); + } } From 3e7b8297468291f5b224480156f778ce64e7ae0c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 10:25:56 +0400 Subject: [PATCH 10/85] test(controller): add tests for activate, forgot-password, reset-password _10_: activate with valid token returns 200 and user profile. _11_: forgot-password with valid email returns 200 and delegates to UserService. _12_: forgot-password with invalid email format returns 400. _13_: reset-password with valid token and matching passwords returns 200, token passed as @RequestParam (not in body). _14_: reset-password with weak new password returns 400 (@ValidPassword). _15_: reset-password with non-matching passwords returns 400 (@PasswordsMatch). Also adds @MockitoBean UserService so the @WebMvcTest slice can construct AuthController, which now depends on this interface. --- .../controller/AuthControllerTest.java | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java b/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java index 464a55d..7596425 100644 --- a/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java @@ -19,8 +19,11 @@ import com.edgareldy.springboottutorial.security.JwtAccessDeniedHandler; import com.edgareldy.springboottutorial.security.JwtAuthenticationEntryPoint; import com.edgareldy.springboottutorial.security.JwtService; +import com.edgareldy.springboottutorial.dto.auth.ForgotPasswordRequest; +import com.edgareldy.springboottutorial.dto.auth.ResetPasswordRequest; import com.edgareldy.springboottutorial.service.AuthService; import com.edgareldy.springboottutorial.service.BlacklistedTokenService; +import com.edgareldy.springboottutorial.service.UserService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -58,6 +61,9 @@ class AuthControllerTest { @MockitoBean private AuthService authService; + @MockitoBean + private UserService userService; + // Unused directly: JwtAuthFilter/SecurityConfig need these to construct, // even though no request in this class carries a JWT. @MockitoBean @@ -169,4 +175,67 @@ void _09_ShouldReturn401_WhenLogoutWithoutAuthentication() throws Exception { .header("Authorization", "Bearer signed-token")) .andExpect(status().isUnauthorized()); } + + @Test + void _10_ShouldReturn200_WhenActivateTokenValid() throws Exception { + UserResponse response = new UserResponse(1L, "Ada", "Lovelace", "ada", "ada@example.com", Role.USER); + when(userService.activate("valid-token")).thenReturn(response); + + mockMvc.perform(get("/api/v1/auth/activate").param("token", "valid-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.username").value("ada")) + .andExpect(jsonPath("$.message").value("Account activated successfully")); + } + + @Test + void _11_ShouldReturn200_WhenForgotPasswordEmailValid() throws Exception { + mockMvc.perform(post("/api/v1/auth/forgot-password") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ForgotPasswordRequest("ada@example.com")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + verify(userService).forgotPassword("ada@example.com"); + } + + @Test + void _12_ShouldReturn400_WhenForgotPasswordEmailInvalid() throws Exception { + mockMvc.perform(post("/api/v1/auth/forgot-password") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ForgotPasswordRequest("not-an-email")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + void _13_ShouldReturn200_WhenResetPasswordRequestValid() throws Exception { + mockMvc.perform(post("/api/v1/auth/reset-password") + .param("token", "reset-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ResetPasswordRequest("Password1@", "Password1@")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Password reset successfully")); + + verify(userService).resetPassword("reset-token", "Password1@"); + } + + @Test + void _14_ShouldReturn400_WhenResetPasswordNewPasswordFailsComplexity() throws Exception { + mockMvc.perform(post("/api/v1/auth/reset-password") + .param("token", "reset-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ResetPasswordRequest("simple", "simple")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + void _15_ShouldReturn400_WhenResetPasswordConfirmDoesNotMatch() throws Exception { + mockMvc.perform(post("/api/v1/auth/reset-password") + .param("token", "reset-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ResetPasswordRequest("Password1@", "Different1@")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } } From 1236e2a64ac7a73ac4d5c6bdf102071f57a7af5b Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 10:26:14 +0400 Subject: [PATCH 11/85] test(service): add UserServiceImplTest for activation and password reset _01_: activate() sets enabled=true and returns user profile. _02_: forgotPassword() sends reset email when email matches a known account. _03_: forgotPassword() sends nothing when email is unknown (no info leakage). _04_: resetPassword() encodes and saves the new password. --- .../service/impl/UserServiceImplTest.java | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java diff --git a/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java b/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java new file mode 100644 index 0000000..3db125b --- /dev/null +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java @@ -0,0 +1,109 @@ +package com.edgareldy.springboottutorial.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.edgareldy.springboottutorial.dto.auth.UserResponse; +import com.edgareldy.springboottutorial.entity.user.AppUser; +import com.edgareldy.springboottutorial.entity.user.Role; +import com.edgareldy.springboottutorial.mapper.AppUserMapper; +import com.edgareldy.springboottutorial.repository.AppUserRepository; +import com.edgareldy.springboottutorial.service.ActivationTokenService; +import com.edgareldy.springboottutorial.service.EmailService; +import com.edgareldy.springboottutorial.service.PasswordResetTokenService; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * Unit tests for {@link UserServiceImpl} covering account activation and + * password reset flows. All dependencies are mocked with Mockito. + *

+ * Created edgar.muhamyangabo on 11/9/26 + * Author : edgar.muhamyangabo + * Date : 11/9/26 + * Project : spring-boot-tutorial + */ +@ExtendWith(MockitoExtension.class) +class UserServiceImplTest { + + @Mock + private AppUserRepository appUserRepository; + @Mock + private AppUserMapper appUserMapper; + @Mock + private ActivationTokenService activationTokenService; + @Mock + private PasswordResetTokenService passwordResetTokenService; + @Mock + private EmailService emailService; + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private UserServiceImpl userService; + + private AppUser user; + private UserResponse userResponse; + + @BeforeEach + void setUp() { + user = AppUser.builder() + .id(1L).firstName("Ada").lastName("Lovelace") + .username("ada").email("ada@example.com") + .password("hashed").role(Role.USER).enabled(false) + .build(); + userResponse = new UserResponse(1L, "Ada", "Lovelace", "ada", "ada@example.com", Role.USER); + } + + @Test + void _01_ShouldEnableUser_WhenActivationTokenValid() { + when(activationTokenService.validate("tok")).thenReturn(user); + when(appUserRepository.save(user)).thenReturn(user); + when(appUserMapper.toResponse(user)).thenReturn(userResponse); + + UserResponse result = userService.activate("tok"); + + assertThat(user.isEnabled()).isTrue(); + assertThat(result.username()).isEqualTo("ada"); + verify(appUserRepository).save(user); + } + + @Test + void _02_ShouldSendResetEmail_WhenEmailKnown() { + when(appUserRepository.findByEmailIgnoreCase("ada@example.com")).thenReturn(Optional.of(user)); + when(passwordResetTokenService.generate(user)).thenReturn("reset-tok"); + + userService.forgotPassword("ada@example.com"); + + verify(emailService).sendPasswordResetEmail(eq(user), eq("reset-tok")); + } + + @Test + void _03_ShouldNotSendEmail_WhenEmailUnknown() { + when(appUserRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty()); + + userService.forgotPassword("unknown@example.com"); + + verify(emailService, org.mockito.Mockito.never()).sendPasswordResetEmail(any(), any()); + } + + @Test + void _04_ShouldEncodeAndSavePassword_WhenResetTokenValid() { + when(passwordResetTokenService.validateAndConsume("reset-tok")).thenReturn(user); + when(passwordEncoder.encode("NewPass1@")).thenReturn("hashed-new"); + + userService.resetPassword("reset-tok", "NewPass1@"); + + assertThat(user.getPassword()).isEqualTo("hashed-new"); + verify(appUserRepository).save(user); + } +} From 6223336d6256d50b112660441b51127da8a5db35 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 10:57:41 +0400 Subject: [PATCH 12/85] fix(security): permit activate, forgot-password, reset-password endpoints These three endpoints must be accessible without a JWT: activate is called from an email link (no session), and forgot/reset are used precisely when the user cannot log in. Without this permit, Spring Security returns 401 on all three. --- .../edgareldy/springboottutorial/config/SecurityConfig.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/config/SecurityConfig.java b/src/main/java/com/edgareldy/springboottutorial/config/SecurityConfig.java index 132d0f9..3da94ff 100644 --- a/src/main/java/com/edgareldy/springboottutorial/config/SecurityConfig.java +++ b/src/main/java/com/edgareldy/springboottutorial/config/SecurityConfig.java @@ -71,7 +71,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler)) .authorizeHttpRequests(authorize -> authorize - .requestMatchers(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/login") + .requestMatchers(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/login", + "/api/v1/auth/forgot-password", "/api/v1/auth/reset-password") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/auth/activate") .permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/actuator/health") .permitAll() From 6bcacfdcc08c9f6bd6057092bcf988f153a5b64c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 10:58:09 +0400 Subject: [PATCH 13/85] fix(test): enable user in SecurityAuthorizationTest after register register() now creates accounts with enabled=false, so a direct login attempt after registration fails with DisabledException. Tests _14_ and _16_ inject AppUserRepository and call enableUser() between the register and login steps, simulating what the activation link does in production. --- .../controller/SecurityAuthorizationTest.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java b/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java index 29b0966..b64c5ee 100644 --- a/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java @@ -10,6 +10,7 @@ import com.edgareldy.springboottutorial.dto.auth.RegisterRequest; import com.edgareldy.springboottutorial.dto.category.CategoryRequest; import com.edgareldy.springboottutorial.dto.customer.CustomerRequest; +import com.edgareldy.springboottutorial.repository.AppUserRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; @@ -49,6 +50,16 @@ class SecurityAuthorizationTest { @Autowired private ObjectMapper objectMapper; + @Autowired + private AppUserRepository appUserRepository; + + private void enableUser(String username) { + appUserRepository.findByUsername(username).ifPresent(u -> { + u.setEnabled(true); + appUserRepository.save(u); + }); + } + @Test void _01_ShouldAllowAccess_WhenListingCategoriesAnonymously() throws Exception { mockMvc.perform(get("/api/v1/categories")).andExpect(status().isOk()); @@ -154,6 +165,8 @@ void _14_ShouldAuthenticateSubsequentRequests_WhenLoginIssuesToken() throws Exce .content(objectMapper.writeValueAsString(register))) .andExpect(status().isCreated()); + enableUser("hopper"); + LoginRequest login = new LoginRequest("hopper@example.com", "Password1@"); MvcResult loginResult = mockMvc.perform(post("/api/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) @@ -190,6 +203,8 @@ void _16_ShouldReturn401_WhenTokenIsUsedAfterLogout() throws Exception { .content(objectMapper.writeValueAsString(register))) .andExpect(status().isCreated()); + enableUser("lovelace"); + MvcResult loginResult = mockMvc.perform(post("/api/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(new LoginRequest("lovelace@example.com", "Password1@")))) From e3e6899e3e2b128174f0aabcbe8af3d680995d1c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 10:58:39 +0400 Subject: [PATCH 14/85] fix(test): add missing mocks in AuthServiceImplTest for register flow AuthServiceImpl.register() now injects ActivationTokenService and EmailService. @InjectMocks requires all constructor dependencies to be present as @Mock; without them Mockito leaves them null and the test fails with NullPointerException on the first call. Also stubs activationTokenService.generate() and verifies that sendActivationEmail() is called, and asserts enabled=false on the saved user. --- .../service/impl/AuthServiceImplTest.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java b/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java index 7b3bf2d..d5c5626 100644 --- a/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java @@ -17,7 +17,9 @@ import com.edgareldy.springboottutorial.mapper.AppUserMapper; import com.edgareldy.springboottutorial.repository.AppUserRepository; import com.edgareldy.springboottutorial.security.JwtService; +import com.edgareldy.springboottutorial.service.ActivationTokenService; import com.edgareldy.springboottutorial.service.BlacklistedTokenService; +import com.edgareldy.springboottutorial.service.EmailService; import java.time.Instant; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -63,6 +65,12 @@ class AuthServiceImplTest { @Mock private BlacklistedTokenService blacklistedTokenService; + @Mock + private ActivationTokenService activationTokenService; + + @Mock + private EmailService emailService; + @InjectMocks private AuthServiceImpl authService; @@ -84,6 +92,7 @@ void _01_ShouldSaveUserWithEncodedPasswordAndDefaultRole_WhenRegistering() { when(passwordEncoder.encode("Password1@")).thenReturn("hashed"); when(appUserRepository.save(any(AppUser.class))).thenReturn(appUser); when(appUserMapper.toResponse(appUser)).thenReturn(userResponse); + when(activationTokenService.generate(appUser)).thenReturn("activation-tok"); assertThat(authService.register(request)).isEqualTo(userResponse); @@ -93,6 +102,8 @@ void _01_ShouldSaveUserWithEncodedPasswordAndDefaultRole_WhenRegistering() { assertThat(savedUserCaptor.getValue().getLastName()).isEqualTo("Lovelace"); assertThat(savedUserCaptor.getValue().getPassword()).isEqualTo("hashed"); assertThat(savedUserCaptor.getValue().getRole()).isEqualTo(Role.USER); + assertThat(savedUserCaptor.getValue().isEnabled()).isFalse(); + verify(emailService).sendActivationEmail(appUser, "activation-tok"); } @Test From 7575e025ac2f4f60408461b89dc92e86d6804f73 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 10:58:53 +0400 Subject: [PATCH 15/85] fix(test): add @ActiveProfiles("test") to UniqueValidatorTest Without this annotation the full application context loads with the default profile, which tries to build EmailServiceImpl with a real JavaMailSender bean. That bean is only declared under the "test" profile (application-test.yml), causing context load failure. --- .../validation/unique/UniqueValidatorTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/com/edgareldy/springboottutorial/validation/unique/UniqueValidatorTest.java b/src/test/java/com/edgareldy/springboottutorial/validation/unique/UniqueValidatorTest.java index 8cdaefb..5b48ab7 100644 --- a/src/test/java/com/edgareldy/springboottutorial/validation/unique/UniqueValidatorTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/validation/unique/UniqueValidatorTest.java @@ -17,6 +17,7 @@ import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; @@ -35,6 +36,7 @@ * Date : 11/8/26 * Project : spring-boot-tutorial */ +@ActiveProfiles("test") @SpringBootTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @Import(UniqueValidatorTest.TestcontainersConfig.class) From 845dd3d29cc0a5b158c77ecf0d369d48b390c556 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 11:46:14 +0400 Subject: [PATCH 16/85] feat(exception): add specific handlers for LockedException (423), DisabledException (403), and ExpiredJwtException (401) LockedException and DisabledException both extend AuthenticationException, so they would previously fall into the generic handleAuthentication handler and return 401. That conflated two distinct conditions: a locked account (should be 423 Locked) and an unactivated account (should be 403 Forbidden - the user is known but not yet permitted). Spring resolves @ExceptionHandler by most-specific type first, so placing these two handlers before the generic AuthenticationException handler is enough. ExpiredJwtException (io.jsonwebtoken) is not an AuthenticationException - it is thrown directly when a controller method calls JwtService.extractAllClaims() with an expired token (e.g. the token-info endpoint). Without a specific handler it would fall through to the catch-all and return 500. --- .../exception/GlobalExceptionHandler.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/exception/GlobalExceptionHandler.java b/src/main/java/com/edgareldy/springboottutorial/exception/GlobalExceptionHandler.java index 906d7e0..0c78ae8 100644 --- a/src/main/java/com/edgareldy/springboottutorial/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/edgareldy/springboottutorial/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.edgareldy.springboottutorial.exception; import com.edgareldy.springboottutorial.dto.common.ApiResponse; +import io.jsonwebtoken.ExpiredJwtException; import jakarta.servlet.http.HttpServletRequest; import java.net.URI; import java.time.Instant; @@ -12,6 +13,8 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; import org.springframework.security.core.AuthenticationException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -50,6 +53,24 @@ public ResponseEntity> handleBusinessRule( return buildResponse(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage(), request, null); } + @ExceptionHandler(LockedException.class) + public ResponseEntity> handleLocked( + LockedException ex, HttpServletRequest request) { + return buildResponse(HttpStatus.LOCKED, "Account is locked", request, null); + } + + @ExceptionHandler(DisabledException.class) + public ResponseEntity> handleDisabled( + DisabledException ex, HttpServletRequest request) { + return buildResponse(HttpStatus.FORBIDDEN, "Account is not activated", request, null); + } + + @ExceptionHandler(ExpiredJwtException.class) + public ResponseEntity> handleExpiredJwt( + ExpiredJwtException ex, HttpServletRequest request) { + return buildResponse(HttpStatus.UNAUTHORIZED, "JWT token has expired", request, null); + } + @ExceptionHandler(AuthenticationException.class) public ResponseEntity> handleAuthentication( AuthenticationException ex, HttpServletRequest request) { From bf743d4cb84d631f73d57d8a4570096fe793d7bb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 11:47:13 +0400 Subject: [PATCH 17/85] test(exception): add tests for LockedException 423, DisabledException 403, and ExpiredJwtException 401 handlers Each new handler gets its own numbered test (_07_, _08_, _09_) following the existing convention: throw the exception directly at the handler method and assert status + message, with no Spring context overhead. --- .../exception/GlobalExceptionHandlerTest.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/test/java/com/edgareldy/springboottutorial/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/edgareldy/springboottutorial/exception/GlobalExceptionHandlerTest.java index a7ed307..fbf46e3 100644 --- a/src/test/java/com/edgareldy/springboottutorial/exception/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/exception/GlobalExceptionHandlerTest.java @@ -5,6 +5,7 @@ import static org.mockito.Mockito.when; import com.edgareldy.springboottutorial.dto.common.ApiResponse; +import io.jsonwebtoken.ExpiredJwtException; import jakarta.servlet.http.HttpServletRequest; import java.net.URI; import java.util.Map; @@ -15,6 +16,8 @@ import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -111,6 +114,42 @@ void _06_ShouldReturn500_WhenUnexpectedExceptionThrown() { assertThat(response.getBody().data().getStatus()).isEqualTo(500); } + @Test + void _07_ShouldReturn423_WhenAccountLocked() { + HttpServletRequest request = mockRequest("/api/v1/auth/login"); + + ResponseEntity> response = + handler.handleLocked(new LockedException("Account is locked"), request); + + assertThat(response.getStatusCode().value()).isEqualTo(423); + assertThat(response.getBody().data().getStatus()).isEqualTo(423); + assertThat(response.getBody().message()).isEqualTo("Account is locked"); + } + + @Test + void _08_ShouldReturn403_WhenAccountDisabled() { + HttpServletRequest request = mockRequest("/api/v1/auth/login"); + + ResponseEntity> response = + handler.handleDisabled(new DisabledException("Account is not activated"), request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody().data().getStatus()).isEqualTo(403); + assertThat(response.getBody().message()).isEqualTo("Account is not activated"); + } + + @Test + void _09_ShouldReturn401_WhenJwtTokenExpired() { + HttpServletRequest request = mockRequest("/api/v1/auth/token-info"); + + ResponseEntity> response = + handler.handleExpiredJwt(mock(ExpiredJwtException.class), request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody().data().getStatus()).isEqualTo(401); + assertThat(response.getBody().message()).isEqualTo("JWT token has expired"); + } + private HttpServletRequest mockRequest(String uri) { HttpServletRequest request = mock(HttpServletRequest.class); when(request.getRequestURI()).thenReturn(uri); From 8b7b6768caddc82f9092e5cd4d76213343c11551 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 11:47:34 +0400 Subject: [PATCH 18/85] feat(security): add refreshExpirationMs to JwtProperties for refresh token support Adds the jwt.refresh-expiration-ms property binding alongside the existing jwt.secret and jwt.expiration-ms. JwtService will use this value to set a longer TTL (7 days) on refresh tokens, keeping it separate from the short-lived access token expiry. --- .../springboottutorial/security/JwtProperties.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/security/JwtProperties.java b/src/main/java/com/edgareldy/springboottutorial/security/JwtProperties.java index 50a3c5b..151a4ca 100644 --- a/src/main/java/com/edgareldy/springboottutorial/security/JwtProperties.java +++ b/src/main/java/com/edgareldy/springboottutorial/security/JwtProperties.java @@ -4,9 +4,9 @@ /** * Type-safe binding of the {@code jwt.*} properties ({@code jwt.secret}, - * {@code jwt.expiration-ms}), replacing two separate {@code @Value} - * injections in {@link JwtService}. Registered via - * {@code @EnableConfigurationProperties} on {@code SecurityConfig} rather + * {@code jwt.expiration-ms}, {@code jwt.refresh-expiration-ms}), replacing + * two separate {@code @Value} injections in {@link JwtService}. Registered + * via {@code @EnableConfigurationProperties} on {@code SecurityConfig} rather * than {@code @ConfigurationPropertiesScan} on the application class, to * keep this branch's JWT-specific wiring self-contained in its own files. *

@@ -16,5 +16,5 @@ * Project : spring-boot-tutorial */ @ConfigurationProperties(prefix = "jwt") -public record JwtProperties(String secret, long expirationMs) { +public record JwtProperties(String secret, long expirationMs, long refreshExpirationMs) { } From 99e4c80efbc2ddbfa2e624601ab601db2cc47d52 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 11:47:48 +0400 Subject: [PATCH 19/85] chore(config): add jwt.refresh-expiration-ms property (7 days default) 604800000 ms = 7 days. JwtService will read this value via JwtProperties to issue refresh tokens with a significantly longer TTL than the 1-hour access token. --- src/main/resources/application.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ce1f343..4b18690 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,3 +11,4 @@ server: jwt: secret: ${JWT_SECRET:dev-only-jwt-secret-please-override-in-production-0123456789} expiration-ms: 3600000 + refresh-expiration-ms: 604800000 From 90849a2ece87ef43b0e287ca9f6ddc10347b3b90 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 11:48:15 +0400 Subject: [PATCH 20/85] feat(security): add generateRefreshToken, isRefreshToken, extractAllClaims to JwtService generateRefreshToken(String username) issues a JWT with token_type="refresh" and the longer refreshExpirationMs TTL from JwtProperties. The token_type claim lets the application distinguish refresh tokens from access tokens programmatically. isRefreshToken(String token) verifies the token_type claim, used by AuthServiceImpl.refresh() (feature/token-management) to reject access tokens submitted to the refresh endpoint. extractAllClaims(String token) exposes the full Claims map, used by the new GET /auth/token-info endpoint so authenticated users can inspect their own JWT. --- .../security/JwtService.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/security/JwtService.java b/src/main/java/com/edgareldy/springboottutorial/security/JwtService.java index 1f3dd18..d3ece61 100644 --- a/src/main/java/com/edgareldy/springboottutorial/security/JwtService.java +++ b/src/main/java/com/edgareldy/springboottutorial/security/JwtService.java @@ -5,6 +5,7 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import java.util.Date; +import java.util.Map; import javax.crypto.SecretKey; import org.springframework.stereotype.Component; @@ -22,12 +23,18 @@ @Component public class JwtService { + private static final String TOKEN_TYPE_CLAIM = "token_type"; + private static final String ACCESS_TOKEN_TYPE = "access"; + private static final String REFRESH_TOKEN_TYPE = "refresh"; + private final SecretKey signingKey; private final long expirationMs; + private final long refreshExpirationMs; public JwtService(JwtProperties jwtProperties) { this.signingKey = Keys.hmacShaKeyFor(jwtProperties.secret().getBytes()); this.expirationMs = jwtProperties.expirationMs(); + this.refreshExpirationMs = jwtProperties.refreshExpirationMs(); } public String generateToken(String username) { @@ -54,6 +61,31 @@ public boolean isTokenValid(String token, String username) { } } + public String generateRefreshToken(String username) { + Date now = new Date(); + Date expiresAt = new Date(now.getTime() + refreshExpirationMs); + return Jwts.builder() + .subject(username) + .claim(TOKEN_TYPE_CLAIM, REFRESH_TOKEN_TYPE) + .issuedAt(now) + .expiration(expiresAt) + .signWith(signingKey) + .compact(); + } + + public boolean isRefreshToken(String token) { + try { + Claims claims = parseClaims(token); + return REFRESH_TOKEN_TYPE.equals(claims.get(TOKEN_TYPE_CLAIM, String.class)); + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + public Map extractAllClaims(String token) { + return parseClaims(token); + } + private Claims parseClaims(String token) { return Jwts.parser() .verifyWith(signingKey) From 875005aa6977642365eedf818a09029ad9503794 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 11:48:42 +0400 Subject: [PATCH 21/85] test(security): update JwtServiceTest for 3-arg JwtProperties and new methods JwtProperties record now has a third field (refreshExpirationMs), so all constructor calls are updated. Added tests: - _06_: refresh token encodes the subject correctly - _07_: isRefreshToken() returns true only for a refresh token (access token returns false), distinguishing the two by the token_type claim - _08_: extractAllClaims() returns a map containing the sub claim --- .../security/JwtServiceTest.java | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/security/JwtServiceTest.java b/src/test/java/com/edgareldy/springboottutorial/security/JwtServiceTest.java index cf71c8e..a28f8ae 100644 --- a/src/test/java/com/edgareldy/springboottutorial/security/JwtServiceTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/security/JwtServiceTest.java @@ -18,12 +18,14 @@ class JwtServiceTest { private static final String SECRET = "unit-test-jwt-secret-at-least-32-bytes-long-0123456789"; + private static final long EXPIRATION_MS = 3600000L; + private static final long REFRESH_EXPIRATION_MS = 604800000L; private JwtService jwtService; @BeforeEach void setUp() { - jwtService = new JwtService(new JwtProperties(SECRET, 3600000L)); + jwtService = new JwtService(new JwtProperties(SECRET, EXPIRATION_MS, REFRESH_EXPIRATION_MS)); } @Test @@ -49,7 +51,7 @@ void _03_ShouldReturnFalse_WhenUsernameDiffers() { @Test void _04_ShouldReturnFalse_WhenTokenExpired() throws InterruptedException { - JwtService shortLivedService = new JwtService(new JwtProperties(SECRET, 1L)); + JwtService shortLivedService = new JwtService(new JwtProperties(SECRET, 1L, REFRESH_EXPIRATION_MS)); String token = shortLivedService.generateToken("ada"); Thread.sleep(10); @@ -62,4 +64,28 @@ void _05_ShouldReturnFalse_WhenTokenTampered() { assertThat(jwtService.isTokenValid(token + "tampered", "ada")).isFalse(); } + + @Test + void _06_ShouldEncodeSubjectInRefreshToken() { + String token = jwtService.generateRefreshToken("ada"); + + assertThat(jwtService.extractUsername(token)).isEqualTo("ada"); + } + + @Test + void _07_ShouldDetectRefreshToken_WhenTokenTypeIsRefresh() { + String refreshToken = jwtService.generateRefreshToken("ada"); + String accessToken = jwtService.generateToken("ada"); + + assertThat(jwtService.isRefreshToken(refreshToken)).isTrue(); + assertThat(jwtService.isRefreshToken(accessToken)).isFalse(); + } + + @Test + void _08_ShouldReturnAllClaims_WhenTokenValid() { + String token = jwtService.generateToken("ada"); + + assertThat(jwtService.extractAllClaims(token)).containsKey("sub"); + assertThat(jwtService.extractAllClaims(token).get("sub")).isEqualTo("ada"); + } } From 1a1b2a10ac6b84e139bff313a99f507b5b9fe4d9 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 11:49:15 +0400 Subject: [PATCH 22/85] feat(controller): add GET /auth/token-info endpoint for JWT introspection Authenticated users can call GET /api/v1/auth/token-info to inspect the claims embedded in their current bearer token (sub, iat, exp, and any extra claims such as roles or jti added by later branches). The endpoint reads the token from the Authorization header, delegates to JwtService.extractAllClaims(), and wraps the result in ApiResponse>. If the token is expired, JwtService.extractAllClaims() throws ExpiredJwtException, which the new handler in GlobalExceptionHandler now maps to 401 instead of 500. --- .../controller/AuthController.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/controller/AuthController.java b/src/main/java/com/edgareldy/springboottutorial/controller/AuthController.java index 6717b41..b371d7e 100644 --- a/src/main/java/com/edgareldy/springboottutorial/controller/AuthController.java +++ b/src/main/java/com/edgareldy/springboottutorial/controller/AuthController.java @@ -5,10 +5,12 @@ import com.edgareldy.springboottutorial.dto.auth.RegisterRequest; import com.edgareldy.springboottutorial.dto.auth.UserResponse; import com.edgareldy.springboottutorial.dto.common.ApiResponse; +import com.edgareldy.springboottutorial.security.JwtService; import com.edgareldy.springboottutorial.service.AuthService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -17,6 +19,7 @@ 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.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -35,7 +38,10 @@ @RequiredArgsConstructor public class AuthController { + private static final String BEARER_PREFIX = "Bearer "; + private final AuthService authService; + private final JwtService jwtService; @PostMapping("/register") @Operation(summary = "Register a new user account") @@ -68,4 +74,19 @@ public ResponseEntity> login(@Valid @RequestBody Login public ResponseEntity> me(@AuthenticationPrincipal UserDetails userDetails) { return ResponseEntity.ok(ApiResponse.success(authService.me(userDetails.getUsername()), "Profile retrieved successfully")); } + + @GetMapping("/token-info") + @Operation(summary = "Inspect the claims of the current bearer token") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Token claims returned"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required or token expired") + }) + public ResponseEntity>> tokenInfo( + @RequestHeader("Authorization") String authHeader) { + String token = authHeader.startsWith(BEARER_PREFIX) + ? authHeader.substring(BEARER_PREFIX.length()) + : authHeader; + Map claims = jwtService.extractAllClaims(token); + return ResponseEntity.ok(ApiResponse.success(claims, "Token claims retrieved successfully")); + } } From 187588abc93435933b82c55b648f43d89d2551bc Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 11:49:48 +0400 Subject: [PATCH 23/85] test(controller): add tests for GET /auth/token-info endpoint _09_: authenticated request with a fake token - jwtService.extractAllClaims() is mocked to return a minimal claims map, asserting that the response contains the sub and token_type fields. _10_: unauthenticated request returns 401, confirming the endpoint is behind the security filter chain. --- .../controller/AuthControllerTest.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java b/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java index 4ca05eb..1dc799c 100644 --- a/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java @@ -1,6 +1,7 @@ package com.edgareldy.springboottutorial.controller; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -20,6 +21,7 @@ import com.edgareldy.springboottutorial.security.JwtAuthenticationEntryPoint; import com.edgareldy.springboottutorial.security.JwtService; import com.edgareldy.springboottutorial.service.AuthService; +import java.util.Map; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -144,4 +146,22 @@ void _07_ShouldReturn401_WhenNotAuthenticated() throws Exception { mockMvc.perform(get("/api/v1/auth/me")) .andExpect(status().isUnauthorized()); } + + @Test + void _09_ShouldReturnClaims_WhenAuthenticatedWithValidToken() throws Exception { + when(jwtService.extractAllClaims(anyString())).thenReturn(Map.of("sub", "ada", "token_type", "access")); + + mockMvc.perform(get("/api/v1/auth/token-info") + .header("Authorization", "Bearer fake-token") + .with(user("ada").roles("USER"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sub").value("ada")) + .andExpect(jsonPath("$.data.token_type").value("access")); + } + + @Test + void _10_ShouldReturn401_WhenTokenInfoCalledUnauthenticated() throws Exception { + mockMvc.perform(get("/api/v1/auth/token-info")) + .andExpect(status().isUnauthorized()); + } } From c933da6db4e1ae0492fc5256b6f27e37f8b96259 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 11:59:49 +0400 Subject: [PATCH 24/85] feat(entity): introduce AuditableToken @MappedSuperclass for token hierarchy All three existing token entities (ActivationToken, BlacklistedToken, PasswordResetToken) repeated the same id, user, token, and createdAt declarations. Extracting them into AuditableToken - a @MappedSuperclass - removes the duplication and gives every future token type (RefreshToken) the same baseline fields for free. Concretely: id (@Id + @GeneratedValue), user (@ManyToOne lazy to AppUser), token (VARCHAR 768, covers JWTs), createdAt. Each subclass keeps only the fields that make it specific. @EqualsAndHashCode(of = "id") lives on the superclass so identity is always PK-based regardless of subclass. --- .../entity/token/AuditableToken.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/main/java/com/edgareldy/springboottutorial/entity/token/AuditableToken.java diff --git a/src/main/java/com/edgareldy/springboottutorial/entity/token/AuditableToken.java b/src/main/java/com/edgareldy/springboottutorial/entity/token/AuditableToken.java new file mode 100644 index 0000000..71560f9 --- /dev/null +++ b/src/main/java/com/edgareldy/springboottutorial/entity/token/AuditableToken.java @@ -0,0 +1,51 @@ +package com.edgareldy.springboottutorial.entity.token; + +import com.edgareldy.springboottutorial.entity.user.AppUser; +import jakarta.persistence.Column; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MappedSuperclass; +import java.time.Instant; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +/** + * Common mapped superclass for all token entities (activation, password-reset, + * blacklisted JWT, refresh). Centralises the primary key, the owning user + * relationship, the raw token string, and the creation timestamp so each + * concrete entity only declares the fields that make it specific. + *

+ * Created edgar.muhamyangabo on 11/9/26 + * Author : edgar.muhamyangabo + * Date : 11/9/26 + * Project : spring-boot-tutorial + */ +@MappedSuperclass +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder +@EqualsAndHashCode(of = "id") +public abstract class AuditableToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private AppUser user; + + @Column(name = "token", nullable = false, length = 768) + private String token; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; +} From 68994921f32e07c261194046b5439bdefac5301d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 11:59:53 +0400 Subject: [PATCH 25/85] refactor(entity): extend AuditableToken in ActivationToken Removes id, user, token, createdAt (now inherited). Switches @Builder to @SuperBuilder so Lombok's builder chain works across the inheritance. @EqualsAndHashCode delegates to the superclass via callSuper = true. --- .../entity/token/ActivationToken.java | 29 +++---------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/entity/token/ActivationToken.java b/src/main/java/com/edgareldy/springboottutorial/entity/token/ActivationToken.java index c94d128..24b1db5 100644 --- a/src/main/java/com/edgareldy/springboottutorial/entity/token/ActivationToken.java +++ b/src/main/java/com/edgareldy/springboottutorial/entity/token/ActivationToken.java @@ -1,22 +1,15 @@ package com.edgareldy.springboottutorial.entity.token; -import com.edgareldy.springboottutorial.entity.user.AppUser; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.Instant; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import lombok.experimental.SuperBuilder; /** * JPA entity mapping the {@code activation_tokens} table. Generated by @@ -34,23 +27,9 @@ @Setter @NoArgsConstructor @AllArgsConstructor -@Builder -@EqualsAndHashCode(of = "id") -public class ActivationToken { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private AppUser user; - - @Column(name = "token", nullable = false) - private String token; - - @Column(name = "created_at", nullable = false) - private Instant createdAt; +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class ActivationToken extends AuditableToken { @Column(name = "expires_at") private Instant expiresAt; From c1e7f5fa41ffba2a8a96c5fb43cba15bc8a7f313 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 11:59:57 +0400 Subject: [PATCH 26/85] refactor(entity): extend AuditableToken in BlacklistedToken Removes id, user, token, createdAt (now inherited). Switches @Builder to @SuperBuilder. The token column keeps its VARCHAR 768 length via the superclass mapping, covering full JWT strings. --- .../entity/token/BlacklistedToken.java | 29 +++---------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/entity/token/BlacklistedToken.java b/src/main/java/com/edgareldy/springboottutorial/entity/token/BlacklistedToken.java index 983734f..042f0a7 100644 --- a/src/main/java/com/edgareldy/springboottutorial/entity/token/BlacklistedToken.java +++ b/src/main/java/com/edgareldy/springboottutorial/entity/token/BlacklistedToken.java @@ -1,22 +1,15 @@ package com.edgareldy.springboottutorial.entity.token; -import com.edgareldy.springboottutorial.entity.user.AppUser; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.Instant; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import lombok.experimental.SuperBuilder; /** * JPA entity mapping the {@code blacklisted_tokens} table. Recorded by @@ -35,20 +28,9 @@ @Setter @NoArgsConstructor @AllArgsConstructor -@Builder -@EqualsAndHashCode(of = "id") -public class BlacklistedToken { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private AppUser user; - - @Column(name = "token", nullable = false, length = 768) - private String token; +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class BlacklistedToken extends AuditableToken { @Column(name = "jti", nullable = false, unique = true) private String jti; @@ -56,9 +38,6 @@ public class BlacklistedToken { @Column(name = "blacklisted_at") private Instant blacklistedAt; - @Column(name = "created_at", nullable = false) - private Instant createdAt; - @Column(name = "expires_at") private Instant expiresAt; } From f37c268cd4f79b7de1b0a8d38fca3853366fcfcb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:00:01 +0400 Subject: [PATCH 27/85] refactor(entity): extend AuditableToken in PasswordResetToken Removes id, user, token (now inherited). Adds createdAt via inheritance; the matching column is added in V11__add_created_at_to_password_reset_tokens. Keeps expiryDate (renamed from expiresAt at schema creation time) untouched. --- .../entity/token/PasswordResetToken.java | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/entity/token/PasswordResetToken.java b/src/main/java/com/edgareldy/springboottutorial/entity/token/PasswordResetToken.java index e25b63e..950efe7 100644 --- a/src/main/java/com/edgareldy/springboottutorial/entity/token/PasswordResetToken.java +++ b/src/main/java/com/edgareldy/springboottutorial/entity/token/PasswordResetToken.java @@ -1,22 +1,15 @@ package com.edgareldy.springboottutorial.entity.token; -import com.edgareldy.springboottutorial.entity.user.AppUser; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.Instant; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import lombok.experimental.SuperBuilder; /** * JPA entity mapping the {@code password_reset_tokens} table. Generated by @@ -35,20 +28,9 @@ @Setter @NoArgsConstructor @AllArgsConstructor -@Builder -@EqualsAndHashCode(of = "id") -public class PasswordResetToken { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private AppUser user; - - @Column(name = "token", nullable = false) - private String token; +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class PasswordResetToken extends AuditableToken { @Column(name = "type", nullable = false) private String type; From 28ed7718e08ac72d8d02165d328c2686e51eea67 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:00:06 +0400 Subject: [PATCH 28/85] flyway(token): V11 - add created_at to password_reset_tokens PasswordResetToken now extends AuditableToken which declares createdAt as NOT NULL. Existing rows (if any during a live migration) are backfilled with NOW() before the NOT NULL constraint is applied. --- .../V11__add_created_at_to_password_reset_tokens.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/resources/db/migration/V11__add_created_at_to_password_reset_tokens.sql diff --git a/src/main/resources/db/migration/V11__add_created_at_to_password_reset_tokens.sql b/src/main/resources/db/migration/V11__add_created_at_to_password_reset_tokens.sql new file mode 100644 index 0000000..8b7411a --- /dev/null +++ b/src/main/resources/db/migration/V11__add_created_at_to_password_reset_tokens.sql @@ -0,0 +1,12 @@ +-- AuditableToken @MappedSuperclass centralises createdAt in all token entities. +-- password_reset_tokens did not have this column yet; backfill with current time +-- for the rare in-flight rows that may exist in a running system. +ALTER TABLE password_reset_tokens + ADD COLUMN created_at TIMESTAMP; + +UPDATE password_reset_tokens + SET created_at = NOW() + WHERE created_at IS NULL; + +ALTER TABLE password_reset_tokens + ALTER COLUMN created_at SET NOT NULL; From 5aad639bde34409dea86f807a6a6348a1df31883 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:00:10 +0400 Subject: [PATCH 29/85] fix(token): set createdAt in PasswordResetTokenServiceImpl.generate() PasswordResetToken now inherits createdAt (NOT NULL) from AuditableToken. The generate() method must supply it at construction time; the Instant is computed once and reused for both createdAt and expiryDate. --- .../service/impl/PasswordResetTokenServiceImpl.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/service/impl/PasswordResetTokenServiceImpl.java b/src/main/java/com/edgareldy/springboottutorial/service/impl/PasswordResetTokenServiceImpl.java index 34b358e..2aae82c 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/impl/PasswordResetTokenServiceImpl.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/impl/PasswordResetTokenServiceImpl.java @@ -39,11 +39,13 @@ public class PasswordResetTokenServiceImpl implements PasswordResetTokenService @Transactional public String generate(AppUser user) { String rawToken = secureTokenGenerator.generate(); + Instant now = Instant.now(); PasswordResetToken passwordResetToken = PasswordResetToken.builder() .user(user) .token(rawToken) + .createdAt(now) .type(TYPE_PASSWORD_RESET) - .expiryDate(Instant.now().plus(EXPIRATION)) + .expiryDate(now.plus(EXPIRATION)) .build(); passwordResetTokenRepository.save(passwordResetToken); return rawToken; From 255b1e1cc1a867b997894691ca6d45c61238c855 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:00:44 +0400 Subject: [PATCH 30/85] feat(repository): add deleteByUser to ActivationTokenRepository Supports purging all previous activation tokens for a user before issuing a new one, so a user who re-requests account activation cannot accumulate stale tokens in the table. --- .../repository/ActivationTokenRepository.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/repository/ActivationTokenRepository.java b/src/main/java/com/edgareldy/springboottutorial/repository/ActivationTokenRepository.java index 7fc8e21..a4bb7d8 100644 --- a/src/main/java/com/edgareldy/springboottutorial/repository/ActivationTokenRepository.java +++ b/src/main/java/com/edgareldy/springboottutorial/repository/ActivationTokenRepository.java @@ -1,6 +1,7 @@ package com.edgareldy.springboottutorial.repository; import com.edgareldy.springboottutorial.entity.token.ActivationToken; +import com.edgareldy.springboottutorial.entity.user.AppUser; import jakarta.persistence.LockModeType; import java.time.Instant; import java.util.Optional; @@ -26,4 +27,6 @@ public interface ActivationTokenRepository extends JpaRepository findByToken(String token); void deleteAllByExpiresAtBefore(Instant instant); + + void deleteByUser(AppUser user); } From c394052e160c5aa0c0a95c31daab012562abdce6 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:00:48 +0400 Subject: [PATCH 31/85] feat(repository): add deleteByUser to PasswordResetTokenRepository Supports purging all previous password-reset tokens for a user before issuing a new one, preventing token accumulation for repeat requests. --- .../repository/PasswordResetTokenRepository.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/repository/PasswordResetTokenRepository.java b/src/main/java/com/edgareldy/springboottutorial/repository/PasswordResetTokenRepository.java index c5e1812..7823b1e 100644 --- a/src/main/java/com/edgareldy/springboottutorial/repository/PasswordResetTokenRepository.java +++ b/src/main/java/com/edgareldy/springboottutorial/repository/PasswordResetTokenRepository.java @@ -1,6 +1,7 @@ package com.edgareldy.springboottutorial.repository; import com.edgareldy.springboottutorial.entity.token.PasswordResetToken; +import com.edgareldy.springboottutorial.entity.user.AppUser; import jakarta.persistence.LockModeType; import java.time.Instant; import java.util.Optional; @@ -26,4 +27,6 @@ public interface PasswordResetTokenRepository extends JpaRepository findByToken(String token); void deleteAllByExpiryDateBefore(Instant instant); + + void deleteByUser(AppUser user); } From 2805df455afd1746da5fb542a720d49c62b3a152 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:00:52 +0400 Subject: [PATCH 32/85] feat(token): purge old activation tokens before generating a new one Calling deleteByUser before save ensures only one valid activation token exists per user at a time. Without this, a user who re-registers or re-requests activation would accumulate unreachable tokens indefinitely. --- .../service/impl/ActivationTokenServiceImpl.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImpl.java b/src/main/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImpl.java index ce48bb4..31e79b2 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImpl.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImpl.java @@ -33,6 +33,7 @@ public class ActivationTokenServiceImpl implements ActivationTokenService { @Override @Transactional public String generate(AppUser user) { + activationTokenRepository.deleteByUser(user); String rawToken = secureTokenGenerator.generate(); Instant now = Instant.now(); ActivationToken activationToken = ActivationToken.builder() From 4bfe987b28d6f42ea6c3884a0e68429ee7ebc09d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:00:59 +0400 Subject: [PATCH 33/85] feat(token): purge old password-reset tokens before generating a new one Same pattern as ActivationTokenServiceImpl: deleteByUser before save keeps at most one valid password-reset token per user. The createdAt field is now set here too, required by the NOT NULL constraint introduced in V11. --- .../service/impl/PasswordResetTokenServiceImpl.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/service/impl/PasswordResetTokenServiceImpl.java b/src/main/java/com/edgareldy/springboottutorial/service/impl/PasswordResetTokenServiceImpl.java index 2aae82c..630a8ac 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/impl/PasswordResetTokenServiceImpl.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/impl/PasswordResetTokenServiceImpl.java @@ -38,6 +38,7 @@ public class PasswordResetTokenServiceImpl implements PasswordResetTokenService @Override @Transactional public String generate(AppUser user) { + passwordResetTokenRepository.deleteByUser(user); String rawToken = secureTokenGenerator.generate(); Instant now = Instant.now(); PasswordResetToken passwordResetToken = PasswordResetToken.builder() From 016ac062a65983e9e45fb70f0a11710ee3ee8d4c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:01:21 +0400 Subject: [PATCH 34/85] feat(exception): add TokenExpiredException extending InvalidTokenException Distinguishes the case where a token is well-formed but past its expiry window from other invalidity cases (not found, already consumed). Both still map to HTTP 400 via the existing handleInvalidToken handler, but callers that need to react differently (item 06: auto-resend activation email) can catch this subtype without touching GlobalExceptionHandler. --- .../exception/TokenExpiredException.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/java/com/edgareldy/springboottutorial/exception/TokenExpiredException.java diff --git a/src/main/java/com/edgareldy/springboottutorial/exception/TokenExpiredException.java b/src/main/java/com/edgareldy/springboottutorial/exception/TokenExpiredException.java new file mode 100644 index 0000000..b48b19b --- /dev/null +++ b/src/main/java/com/edgareldy/springboottutorial/exception/TokenExpiredException.java @@ -0,0 +1,20 @@ +package com.edgareldy.springboottutorial.exception; + +/** + * Thrown when a token is structurally valid but its validity window has + * passed. Extends {@link InvalidTokenException} so the existing + * {@code GlobalExceptionHandler.handleInvalidToken} handler returns HTTP 400, + * while callers that need to distinguish expiry from other invalidity + * (e.g., to auto-resend a new activation link) can catch this subtype. + *

+ * Created edgar.muhamyangabo on 11/9/26 + * Author : edgar.muhamyangabo + * Date : 11/9/26 + * Project : spring-boot-tutorial + */ +public class TokenExpiredException extends InvalidTokenException { + + public TokenExpiredException(String message) { + super(message); + } +} From 27c234177ce4b7ac46cc81ef60e30e8a9733f767 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:01:26 +0400 Subject: [PATCH 35/85] feat(token): throw TokenExpiredException instead of InvalidTokenException on expiry ActivationTokenServiceImpl.validate() now distinguishes the two failure modes: not-found/already-used stays InvalidTokenException (400), but an expired token becomes TokenExpiredException. feature/user-management will catch TokenExpiredException in UserServiceImpl.activate() to auto-resend a fresh activation link instead of returning a plain 400 error. --- .../service/impl/ActivationTokenServiceImpl.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImpl.java b/src/main/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImpl.java index 31e79b2..84630b6 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImpl.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImpl.java @@ -3,6 +3,7 @@ import com.edgareldy.springboottutorial.entity.token.ActivationToken; import com.edgareldy.springboottutorial.entity.user.AppUser; import com.edgareldy.springboottutorial.exception.InvalidTokenException; +import com.edgareldy.springboottutorial.exception.TokenExpiredException; import com.edgareldy.springboottutorial.repository.ActivationTokenRepository; import com.edgareldy.springboottutorial.security.SecureTokenGenerator; import com.edgareldy.springboottutorial.service.ActivationTokenService; @@ -56,7 +57,7 @@ public AppUser validate(String token) { } if (activationToken.getExpiresAt() != null && activationToken.getExpiresAt().isBefore(Instant.now())) { - throw new InvalidTokenException("Activation token expired"); + throw new TokenExpiredException("Activation token expired"); } activationToken.setValidatedAt(Instant.now()); activationTokenRepository.save(activationToken); From 8858de2b85b8891edf5017716e6098987d91e25f Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:02:50 +0400 Subject: [PATCH 36/85] feat(entity): add RefreshToken extending AuditableToken Persists the opaque refresh token alongside its expiry and optional revokedAt timestamp. By storing the token in the database rather than only in a JWT, the server can invalidate it immediately on logout without waiting for JWT expiry. --- .../entity/token/RefreshToken.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/main/java/com/edgareldy/springboottutorial/entity/token/RefreshToken.java diff --git a/src/main/java/com/edgareldy/springboottutorial/entity/token/RefreshToken.java b/src/main/java/com/edgareldy/springboottutorial/entity/token/RefreshToken.java new file mode 100644 index 0000000..b8d0dd3 --- /dev/null +++ b/src/main/java/com/edgareldy/springboottutorial/entity/token/RefreshToken.java @@ -0,0 +1,40 @@ +package com.edgareldy.springboottutorial.entity.token; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +/** + * JPA entity mapping the {@code refresh_tokens} table. Issued alongside the + * access token on login and stored in the database so it can be validated and + * rotated on each refresh call. Deleted on logout so it cannot be reused + * after the session ends. + *

+ * Created edgar.muhamyangabo on 11/9/26 + * Author : edgar.muhamyangabo + * Date : 11/9/26 + * Project : spring-boot-tutorial + */ +@Entity +@Table(name = "refresh_tokens") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class RefreshToken extends AuditableToken { + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "revoked_at") + private Instant revokedAt; +} From 4ac90598790f107520c454f3136c237ebd9dd6f5 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:02:54 +0400 Subject: [PATCH 37/85] flyway(token): V12 - create refresh_tokens table Stores one row per active refresh token, keyed by the opaque token string. The unique constraint on token prevents the same string from being stored twice; the user_id index speeds up the deleteByUser lookup on logout. --- .../db/migration/V12__add_refresh_tokens.sql | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/main/resources/db/migration/V12__add_refresh_tokens.sql diff --git a/src/main/resources/db/migration/V12__add_refresh_tokens.sql b/src/main/resources/db/migration/V12__add_refresh_tokens.sql new file mode 100644 index 0000000..b04638e --- /dev/null +++ b/src/main/resources/db/migration/V12__add_refresh_tokens.sql @@ -0,0 +1,14 @@ +-- Refresh tokens persist the opaque token string issued alongside every +-- access token so the server can validate and rotate it. Each row is owned +-- by a user and deleted on logout. + +CREATE TABLE refresh_tokens ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app_users (id), + token VARCHAR(768) NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP +); + +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens (user_id); From 6620f238ec1bb4b6cbb2740e7ac78d68be0f8e15 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:02:58 +0400 Subject: [PATCH 38/85] feat(repository): add RefreshTokenRepository Standard JpaRepository with findByToken, deleteByUser (called on logout and at the start of generate to enforce one-token-per-user), and deleteAllByExpiresAtBefore for the cleanup scheduler. --- .../repository/RefreshTokenRepository.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/main/java/com/edgareldy/springboottutorial/repository/RefreshTokenRepository.java diff --git a/src/main/java/com/edgareldy/springboottutorial/repository/RefreshTokenRepository.java b/src/main/java/com/edgareldy/springboottutorial/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..7137d0d --- /dev/null +++ b/src/main/java/com/edgareldy/springboottutorial/repository/RefreshTokenRepository.java @@ -0,0 +1,24 @@ +package com.edgareldy.springboottutorial.repository; + +import com.edgareldy.springboottutorial.entity.token.RefreshToken; +import com.edgareldy.springboottutorial.entity.user.AppUser; +import java.time.Instant; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * Spring Data JPA repository for {@link RefreshToken}. + *

+ * Created edgar.muhamyangabo on 11/9/26 + * Author : edgar.muhamyangabo + * Date : 11/9/26 + * Project : spring-boot-tutorial + */ +public interface RefreshTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + void deleteByUser(AppUser user); + + void deleteAllByExpiresAtBefore(Instant instant); +} From f50b38a82521b8fa022f58f5222d4c7a291d759d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:03:03 +0400 Subject: [PATCH 39/85] feat(service): add RefreshTokenService interface Defines generate (issue a new token, purge previous ones for that user), validate (look up and check expiry), and deleteByUser (called on logout). Controllers and tests depend on this interface, not the implementation. --- .../service/RefreshTokenService.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/main/java/com/edgareldy/springboottutorial/service/RefreshTokenService.java diff --git a/src/main/java/com/edgareldy/springboottutorial/service/RefreshTokenService.java b/src/main/java/com/edgareldy/springboottutorial/service/RefreshTokenService.java new file mode 100644 index 0000000..2c05ce3 --- /dev/null +++ b/src/main/java/com/edgareldy/springboottutorial/service/RefreshTokenService.java @@ -0,0 +1,23 @@ +package com.edgareldy.springboottutorial.service; + +import com.edgareldy.springboottutorial.entity.token.RefreshToken; +import com.edgareldy.springboottutorial.entity.user.AppUser; + +/** + * Contract for refresh token issuance, validation, and revocation. Called by + * {@link AuthService} at login (issue), refresh (validate then rotate), and + * logout (delete). + *

+ * Created edgar.muhamyangabo on 11/9/26 + * Author : edgar.muhamyangabo + * Date : 11/9/26 + * Project : spring-boot-tutorial + */ +public interface RefreshTokenService { + + RefreshToken generate(AppUser user); + + RefreshToken validate(String rawToken); + + void deleteByUser(AppUser user); +} From da5c20818002cd3dcd1fe487401aeec4e15f9938 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:03:08 +0400 Subject: [PATCH 40/85] feat(service): implement RefreshTokenServiceImpl generate() calls deleteByUser first (one-token-per-user invariant), then builds the row with the opaque string from JwtService.generateRefreshToken and the expiry from JwtProperties.refreshExpirationMs. validate() throws TokenExpiredException (a subtype of InvalidTokenException, HTTP 400) when the row's expiresAt is in the past, distinguishing it from the not-found case. deleteByUser() is a thin delegation to the repository. --- .../service/impl/RefreshTokenServiceImpl.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/main/java/com/edgareldy/springboottutorial/service/impl/RefreshTokenServiceImpl.java diff --git a/src/main/java/com/edgareldy/springboottutorial/service/impl/RefreshTokenServiceImpl.java b/src/main/java/com/edgareldy/springboottutorial/service/impl/RefreshTokenServiceImpl.java new file mode 100644 index 0000000..15b6901 --- /dev/null +++ b/src/main/java/com/edgareldy/springboottutorial/service/impl/RefreshTokenServiceImpl.java @@ -0,0 +1,67 @@ +package com.edgareldy.springboottutorial.service.impl; + +import com.edgareldy.springboottutorial.entity.token.RefreshToken; +import com.edgareldy.springboottutorial.entity.user.AppUser; +import com.edgareldy.springboottutorial.exception.InvalidTokenException; +import com.edgareldy.springboottutorial.exception.TokenExpiredException; +import com.edgareldy.springboottutorial.repository.RefreshTokenRepository; +import com.edgareldy.springboottutorial.security.JwtProperties; +import com.edgareldy.springboottutorial.security.JwtService; +import com.edgareldy.springboottutorial.service.RefreshTokenService; +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Default {@link RefreshTokenService} implementation backed by + * {@link RefreshTokenRepository}. Tokens are opaque strings generated by + * {@link JwtService#generateRefreshToken} and stored in the database; each + * validate call checks expiry against the database row, not the JWT + * signature, so a revoked token is detected even if the JWT has not expired. + *

+ * Created edgar.muhamyangabo on 11/9/26 + * Author : edgar.muhamyangabo + * Date : 11/9/26 + * Project : spring-boot-tutorial + */ +@Service +@RequiredArgsConstructor +public class RefreshTokenServiceImpl implements RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final JwtService jwtService; + private final JwtProperties jwtProperties; + + @Override + @Transactional + public RefreshToken generate(AppUser user) { + refreshTokenRepository.deleteByUser(user); + Instant now = Instant.now(); + String rawToken = jwtService.generateRefreshToken(user.getUsername()); + RefreshToken refreshToken = RefreshToken.builder() + .user(user) + .token(rawToken) + .createdAt(now) + .expiresAt(now.plusMillis(jwtProperties.refreshExpirationMs())) + .build(); + return refreshTokenRepository.save(refreshToken); + } + + @Override + @Transactional(readOnly = true) + public RefreshToken validate(String rawToken) { + RefreshToken refreshToken = refreshTokenRepository.findByToken(rawToken) + .orElseThrow(() -> new InvalidTokenException("Invalid refresh token")); + if (refreshToken.getExpiresAt().isBefore(Instant.now())) { + throw new TokenExpiredException("Refresh token has expired"); + } + return refreshToken; + } + + @Override + @Transactional + public void deleteByUser(AppUser user) { + refreshTokenRepository.deleteByUser(user); + } +} From fc44a928e0a159998c6a3ab96afb51d6fa78e1bd Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:03:12 +0400 Subject: [PATCH 41/85] feat(dto): add refresh_token field to AuthResponse The login and refresh endpoints now return both the short-lived access token and the long-lived refresh token in one response, so the client can store both without a second round trip. toString() masks both tokens (***). --- .../springboottutorial/dto/auth/AuthResponse.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/dto/auth/AuthResponse.java b/src/main/java/com/edgareldy/springboottutorial/dto/auth/AuthResponse.java index 308ed7c..12747ff 100644 --- a/src/main/java/com/edgareldy/springboottutorial/dto/auth/AuthResponse.java +++ b/src/main/java/com/edgareldy/springboottutorial/dto/auth/AuthResponse.java @@ -20,7 +20,10 @@ public record AuthResponse( @JsonProperty("token_type") String tokenType, - String username + String username, + + @JsonProperty("refresh_token") + String refreshToken ) { // Overrides the compiler-generated toString(): LoggingAspect logs every @@ -29,6 +32,7 @@ public record AuthResponse( // a live bearer credential, in the application logs. @Override public String toString() { - return "AuthResponse[token=***, tokenType=%s, username=%s]".formatted(tokenType, username); + return "AuthResponse[token=***, tokenType=%s, username=%s, refreshToken=***]" + .formatted(tokenType, username); } } From bc755de8f5bebfa45adf7b94e235eb9a4179e60f Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:03:16 +0400 Subject: [PATCH 42/85] feat(service): add refresh() to AuthService contract New method accepts the raw refresh token string and returns a new AuthResponse (rotated access + refresh tokens). AuthServiceImpl implements the token-rotation logic; controllers and tests depend on this interface as usual. --- .../com/edgareldy/springboottutorial/service/AuthService.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/service/AuthService.java b/src/main/java/com/edgareldy/springboottutorial/service/AuthService.java index d4f308a..7ef5b82 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/AuthService.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/AuthService.java @@ -20,6 +20,8 @@ public interface AuthService { AuthResponse login(LoginRequest request); + AuthResponse refresh(String refreshToken); + void logout(String rawToken); UserResponse me(String username); From bb26a32f61d799bce5e51de3f6b1760776d17698 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:03:21 +0400 Subject: [PATCH 43/85] feat(service): issue and rotate refresh tokens in AuthServiceImpl login() now calls refreshTokenService.generate() alongside the access-token generation and returns both in AuthResponse. refresh() validates the incoming refresh token (throws if missing/expired), issues a new access token, then rotates the refresh token (generate calls deleteByUser implicitly so old tokens are purged atomically). logout() additionally calls refreshTokenService.deleteByUser() so the refresh token is invalidated alongside the access-token blacklist entry. --- .../service/impl/AuthServiceImpl.java | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java b/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java index 718140f..7754d18 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java @@ -11,10 +11,12 @@ import com.edgareldy.springboottutorial.mapper.AppUserMapper; import com.edgareldy.springboottutorial.repository.AppUserRepository; import com.edgareldy.springboottutorial.security.JwtService; +import com.edgareldy.springboottutorial.entity.token.RefreshToken; import com.edgareldy.springboottutorial.service.ActivationTokenService; import com.edgareldy.springboottutorial.service.AuthService; import com.edgareldy.springboottutorial.service.BlacklistedTokenService; import com.edgareldy.springboottutorial.service.EmailService; +import com.edgareldy.springboottutorial.service.RefreshTokenService; import java.time.Instant; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.AuthenticationManager; @@ -47,6 +49,7 @@ public class AuthServiceImpl implements AuthService { private final BlacklistedTokenService blacklistedTokenService; private final ActivationTokenService activationTokenService; private final EmailService emailService; + private final RefreshTokenService refreshTokenService; @Override @Transactional @@ -76,8 +79,21 @@ public AuthResponse login(LoginRequest request) { .orElseThrow(() -> new BadCredentialsException("Invalid credentials")); authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(appUser.getUsername(), request.password())); - String token = jwtService.generateToken(appUser.getUsername()); - return new AuthResponse(token, BEARER_TOKEN_TYPE, appUser.getUsername()); + String accessToken = jwtService.generateToken(appUser.getUsername()); + RefreshToken refreshToken = refreshTokenService.generate(appUser); + return new AuthResponse(accessToken, BEARER_TOKEN_TYPE, appUser.getUsername(), + refreshToken.getToken()); + } + + @Override + @Transactional + public AuthResponse refresh(String rawRefreshToken) { + RefreshToken refreshToken = refreshTokenService.validate(rawRefreshToken); + AppUser appUser = refreshToken.getUser(); + String newAccessToken = jwtService.generateToken(appUser.getUsername()); + RefreshToken newRefreshToken = refreshTokenService.generate(appUser); + return new AuthResponse(newAccessToken, BEARER_TOKEN_TYPE, appUser.getUsername(), + newRefreshToken.getToken()); } @Override @@ -89,6 +105,7 @@ public void logout(String rawToken) { AppUser user = appUserRepository.findByUsername(username) .orElseThrow(() -> new ResourceNotFoundException("User not found with username " + username)); blacklistedTokenService.blacklist(user, rawToken, jti, expiresAt); + refreshTokenService.deleteByUser(user); } @Override From c7b925123d215c9d177acfbddc190aee999521a8 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:03:25 +0400 Subject: [PATCH 44/85] feat(controller): add POST /api/v1/auth/refresh-token endpoint Accepts the refresh token in the Authorization header (Bearer scheme), delegates to AuthService.refresh(), and returns the rotated AuthResponse (new access token + new refresh token). Public endpoint: no authentication required since the caller may not have a valid access token. --- .../springboottutorial/controller/AuthController.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/controller/AuthController.java b/src/main/java/com/edgareldy/springboottutorial/controller/AuthController.java index 2ffebb1..76fb755 100644 --- a/src/main/java/com/edgareldy/springboottutorial/controller/AuthController.java +++ b/src/main/java/com/edgareldy/springboottutorial/controller/AuthController.java @@ -46,6 +46,15 @@ public class AuthController { private final UserService userService; private final JwtService jwtService; + @PostMapping("/refresh-token") + public ResponseEntity> refreshToken( + @RequestHeader("Authorization") String authHeader) { + String rawToken = authHeader.startsWith(BEARER_PREFIX) + ? authHeader.substring(BEARER_PREFIX.length()) + : authHeader; + return ResponseEntity.ok(ApiResponse.success(authService.refresh(rawToken), "Token refreshed successfully")); + } + @PostMapping("/register") public ResponseEntity> register(@Valid @RequestBody RegisterRequest request) { return ResponseEntity.status(HttpStatus.CREATED) From ebf694c3c929c55cf65bcc3e006c278322e37c7f Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:03:29 +0400 Subject: [PATCH 45/85] feat(security): permit POST /api/v1/auth/refresh-token without authentication The caller may no longer have a valid access token when they hit this endpoint (that is precisely the reason they are refreshing), so the route must be accessible without a bearer token - same treatment as /login. --- .../edgareldy/springboottutorial/config/SecurityConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/config/SecurityConfig.java b/src/main/java/com/edgareldy/springboottutorial/config/SecurityConfig.java index 3da94ff..acd7447 100644 --- a/src/main/java/com/edgareldy/springboottutorial/config/SecurityConfig.java +++ b/src/main/java/com/edgareldy/springboottutorial/config/SecurityConfig.java @@ -72,7 +72,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .accessDeniedHandler(accessDeniedHandler)) .authorizeHttpRequests(authorize -> authorize .requestMatchers(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/login", - "/api/v1/auth/forgot-password", "/api/v1/auth/reset-password") + "/api/v1/auth/forgot-password", "/api/v1/auth/reset-password", + "/api/v1/auth/refresh-token") .permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/auth/activate") .permitAll() From d71da9dac5cb28ed2dd1fa1a0faa126572eb74c7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:05:22 +0400 Subject: [PATCH 46/85] test(dto): update AuthDtoMaskingTest for 4-arg AuthResponse and refresh_token masking AuthResponse gained a refreshToken field; the toString() override must mask it. Updated test passes all four args and asserts the refresh token string is not present in the output. --- .../springboottutorial/dto/auth/AuthDtoMaskingTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/dto/auth/AuthDtoMaskingTest.java b/src/test/java/com/edgareldy/springboottutorial/dto/auth/AuthDtoMaskingTest.java index bad9f46..ec05ae5 100644 --- a/src/test/java/com/edgareldy/springboottutorial/dto/auth/AuthDtoMaskingTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/dto/auth/AuthDtoMaskingTest.java @@ -36,8 +36,10 @@ void _02_ShouldMaskPassword_WhenLoginRequestPrinted() { @Test void _03_ShouldMaskToken_WhenAuthResponsePrinted() { - String result = new AuthResponse("signed.jwt.token", "Bearer", "ada").toString(); + String result = new AuthResponse("signed.jwt.token", "Bearer", "ada", "refresh.jwt.token").toString(); - assertThat(result).contains("Bearer", "ada").doesNotContain("signed.jwt.token"); + assertThat(result) + .contains("Bearer", "ada") + .doesNotContain("signed.jwt.token", "refresh.jwt.token"); } } From dea8ed1d963fce92d5b0f18cc6b6608e37bd2ccb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:05:27 +0400 Subject: [PATCH 47/85] test(controller): update AuthControllerTest for RefreshTokenService and refresh-token endpoint - Adds @MockitoBean RefreshTokenService so the slice can still build AuthServiceImpl (which now injects RefreshTokenService). - Fixes _04_ login test: AuthResponse constructor now requires 4 args. - Adds _18_ for POST /auth/refresh-token: valid refresh header -> rotated tokens. --- .../controller/AuthControllerTest.java | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java b/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java index 41707c7..175455b 100644 --- a/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java @@ -24,6 +24,7 @@ import com.edgareldy.springboottutorial.dto.auth.ResetPasswordRequest; import com.edgareldy.springboottutorial.service.AuthService; import com.edgareldy.springboottutorial.service.BlacklistedTokenService; +import com.edgareldy.springboottutorial.service.RefreshTokenService; import com.edgareldy.springboottutorial.service.UserService; import java.util.Map; import org.junit.jupiter.api.Test; @@ -78,6 +79,10 @@ class AuthControllerTest { @MockitoBean private BlacklistedTokenService blacklistedTokenService; + // AuthServiceImpl needs RefreshTokenService; mocked so the slice can construct. + @MockitoBean + private RefreshTokenService refreshTokenService; + @Test void _01_ShouldReturn201_WhenRegisterRequestValid() throws Exception { UserResponse response = new UserResponse(1L, "Ada", "Lovelace", "ada", "ada@example.com", Role.USER); @@ -116,7 +121,7 @@ void _03_ShouldReturn400_WhenFirstNameBlank() throws Exception { @Test void _04_ShouldReturn200_WhenCredentialsValid() throws Exception { - when(authService.login(any())).thenReturn(new AuthResponse("signed-token", "Bearer", "ada")); + when(authService.login(any())).thenReturn(new AuthResponse("signed-token", "Bearer", "ada", "refresh-token")); mockMvc.perform(post("/api/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) @@ -258,4 +263,17 @@ void _17_ShouldReturn401_WhenTokenInfoCalledUnauthenticated() throws Exception { mockMvc.perform(get("/api/v1/auth/token-info")) .andExpect(status().isUnauthorized()); } + + @Test + void _18_ShouldReturn200_WhenRefreshTokenValid() throws Exception { + when(authService.refresh("refresh-token")) + .thenReturn(new AuthResponse("new-access-token", "Bearer", "ada", "new-refresh-token")); + + mockMvc.perform(post("/api/v1/auth/refresh-token") + .header("Authorization", "Bearer refresh-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.token").value("new-access-token")) + .andExpect(jsonPath("$.data.refresh_token").value("new-refresh-token")) + .andExpect(jsonPath("$.message").value("Token refreshed successfully")); + } } From 3dd1a524bb66b7168f5a6bea67aa9205a40d2f13 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:05:32 +0400 Subject: [PATCH 48/85] test(service): update AuthServiceImplTest for RefreshTokenService and logout coverage - Adds @Mock RefreshTokenService. - Fixes _04_ login test: mocks refreshTokenService.generate(), uses 4-arg AuthResponse, and verifies generate() is called. - Renames and extends _08_ logout test to also verify refreshTokenService.deleteByUser(). --- .../service/impl/AuthServiceImplTest.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java b/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java index d5c5626..3fbeeeb 100644 --- a/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java @@ -17,9 +17,11 @@ import com.edgareldy.springboottutorial.mapper.AppUserMapper; import com.edgareldy.springboottutorial.repository.AppUserRepository; import com.edgareldy.springboottutorial.security.JwtService; +import com.edgareldy.springboottutorial.entity.token.RefreshToken; import com.edgareldy.springboottutorial.service.ActivationTokenService; import com.edgareldy.springboottutorial.service.BlacklistedTokenService; import com.edgareldy.springboottutorial.service.EmailService; +import com.edgareldy.springboottutorial.service.RefreshTokenService; import java.time.Instant; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -71,6 +73,9 @@ class AuthServiceImplTest { @Mock private EmailService emailService; + @Mock + private RefreshTokenService refreshTokenService; + @InjectMocks private AuthServiceImpl authService; @@ -109,13 +114,17 @@ void _01_ShouldSaveUserWithEncodedPasswordAndDefaultRole_WhenRegistering() { @Test void _04_ShouldReturnToken_WhenCredentialsValid() { LoginRequest request = new LoginRequest("ada@example.com", "password123"); + RefreshToken fakeRefreshToken = RefreshToken.builder() + .token("refresh-token").build(); when(appUserRepository.findByEmailIgnoreCase("ada@example.com")).thenReturn(Optional.of(appUser)); when(jwtService.generateToken("ada")).thenReturn("signed-token"); + when(refreshTokenService.generate(appUser)).thenReturn(fakeRefreshToken); AuthResponse response = authService.login(request); - assertThat(response).isEqualTo(new AuthResponse("signed-token", "Bearer", "ada")); + assertThat(response).isEqualTo(new AuthResponse("signed-token", "Bearer", "ada", "refresh-token")); verify(authenticationManager).authenticate(new UsernamePasswordAuthenticationToken("ada", "password123")); + verify(refreshTokenService).generate(appUser); } @Test @@ -159,7 +168,7 @@ void _07_ShouldThrowNotFound_WhenUserMissing() { } @Test - void _08_ShouldBlacklistToken_WhenLogoutCalled() { + void _08_ShouldBlacklistTokenAndDeleteRefreshToken_WhenLogoutCalled() { String rawToken = "signed-token"; String jti = "jti-123"; Instant expiresAt = Instant.now().plusSeconds(3600); @@ -171,6 +180,7 @@ void _08_ShouldBlacklistToken_WhenLogoutCalled() { authService.logout(rawToken); verify(blacklistedTokenService).blacklist(appUser, rawToken, jti, expiresAt); + verify(refreshTokenService).deleteByUser(appUser); } @Test From b3b08f223a0634d18b6e7f8021b4551700776bb8 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:05:38 +0400 Subject: [PATCH 49/85] test(token): update ActivationTokenServiceImplTest for purge and TokenExpiredException - _01_: verifies deleteByUser is called before save (item 04 purge behavior). - _05_: asserts TokenExpiredException specifically (item 12) instead of the base InvalidTokenException, making the subtype contract observable in tests. --- .../service/impl/ActivationTokenServiceImplTest.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImplTest.java b/src/test/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImplTest.java index 2776f40..1dbe4a4 100644 --- a/src/test/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImplTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImplTest.java @@ -10,6 +10,7 @@ import com.edgareldy.springboottutorial.entity.token.ActivationToken; import com.edgareldy.springboottutorial.entity.user.AppUser; import com.edgareldy.springboottutorial.exception.InvalidTokenException; +import com.edgareldy.springboottutorial.exception.TokenExpiredException; import com.edgareldy.springboottutorial.repository.ActivationTokenRepository; import com.edgareldy.springboottutorial.security.SecureTokenGenerator; import java.time.Instant; @@ -52,12 +53,13 @@ void setUp() { } @Test - void _01_ShouldSaveTokenAndReturnRawValue_WhenTokenIsGenerated() { + void _01_ShouldPurgePreviousTokensThenSave_WhenTokenIsGenerated() { when(secureTokenGenerator.generate()).thenReturn("raw-token"); String result = activationTokenService.generate(user); assertThat(result).isEqualTo("raw-token"); + verify(activationTokenRepository).deleteByUser(user); ArgumentCaptor captor = ArgumentCaptor.forClass(ActivationToken.class); verify(activationTokenRepository).save(captor.capture()); assertThat(captor.getValue().getUser()).isEqualTo(user); @@ -105,7 +107,7 @@ void _04_ShouldThrowException_WhenTokenIsAlreadyValidated() { } @Test - void _05_ShouldThrowException_WhenTokenIsExpired() { + void _05_ShouldThrowTokenExpiredException_WhenTokenIsExpired() { ActivationToken activationToken = ActivationToken.builder() .id(1L).user(user).token("raw-token") .createdAt(Instant.now().minus(2, ChronoUnit.DAYS)) @@ -114,6 +116,6 @@ void _05_ShouldThrowException_WhenTokenIsExpired() { when(activationTokenRepository.findByToken("raw-token")).thenReturn(Optional.of(activationToken)); assertThatThrownBy(() -> activationTokenService.validate("raw-token")) - .isInstanceOf(InvalidTokenException.class); + .isInstanceOf(TokenExpiredException.class); } } From ada88333b7d9ad06a4833b9ee22a1ea9f5553a52 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:05:43 +0400 Subject: [PATCH 50/85] test(token): update PasswordResetTokenServiceImplTest for purge behavior _01_: verifies deleteByUser is called before save (item 04 purge behavior). --- .../service/impl/PasswordResetTokenServiceImplTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/service/impl/PasswordResetTokenServiceImplTest.java b/src/test/java/com/edgareldy/springboottutorial/service/impl/PasswordResetTokenServiceImplTest.java index 545bd99..699aab8 100644 --- a/src/test/java/com/edgareldy/springboottutorial/service/impl/PasswordResetTokenServiceImplTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/PasswordResetTokenServiceImplTest.java @@ -53,12 +53,13 @@ void setUp() { } @Test - void _01_ShouldSaveTokenAndReturnRawValue_WhenTokenIsGenerated() { + void _01_ShouldPurgePreviousTokensThenSave_WhenTokenIsGenerated() { when(secureTokenGenerator.generate()).thenReturn("raw-token"); String result = passwordResetTokenService.generate(user); assertThat(result).isEqualTo("raw-token"); + verify(passwordResetTokenRepository).deleteByUser(user); ArgumentCaptor captor = ArgumentCaptor.forClass(PasswordResetToken.class); verify(passwordResetTokenRepository).save(captor.capture()); assertThat(captor.getValue().getUser()).isEqualTo(user); From 61b51e6ce9885e3e435066bfdf08841d32cdfae1 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:05:48 +0400 Subject: [PATCH 51/85] test(service): add RefreshTokenServiceImplTest 5 unit tests covering generate (purge + save), validate (happy path), invalid token, expired token (TokenExpiredException), and deleteByUser delegation to the repository. --- .../impl/RefreshTokenServiceImplTest.java | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/test/java/com/edgareldy/springboottutorial/service/impl/RefreshTokenServiceImplTest.java diff --git a/src/test/java/com/edgareldy/springboottutorial/service/impl/RefreshTokenServiceImplTest.java b/src/test/java/com/edgareldy/springboottutorial/service/impl/RefreshTokenServiceImplTest.java new file mode 100644 index 0000000..25e3f02 --- /dev/null +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/RefreshTokenServiceImplTest.java @@ -0,0 +1,116 @@ +package com.edgareldy.springboottutorial.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.edgareldy.springboottutorial.entity.token.RefreshToken; +import com.edgareldy.springboottutorial.entity.user.AppUser; +import com.edgareldy.springboottutorial.exception.InvalidTokenException; +import com.edgareldy.springboottutorial.exception.TokenExpiredException; +import com.edgareldy.springboottutorial.repository.RefreshTokenRepository; +import com.edgareldy.springboottutorial.security.JwtProperties; +import com.edgareldy.springboottutorial.security.JwtService; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link RefreshTokenServiceImpl}, with + * {@link RefreshTokenRepository}, {@link JwtService}, and + * {@link JwtProperties} mocked. + *

+ * Created edgar.muhamyangabo on 11/9/26 + * Author : edgar.muhamyangabo + * Date : 11/9/26 + * Project : spring-boot-tutorial + */ +@ExtendWith(MockitoExtension.class) +class RefreshTokenServiceImplTest { + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Mock + private JwtService jwtService; + + @Mock + private JwtProperties jwtProperties; + + @InjectMocks + private RefreshTokenServiceImpl refreshTokenService; + + private AppUser user; + + @BeforeEach + void setUp() { + user = AppUser.builder().id(1L).username("ada").email("ada@example.com").build(); + } + + @Test + void _01_ShouldPurgePreviousTokensThenSave_WhenGenerating() { + when(jwtService.generateRefreshToken("ada")).thenReturn("raw-refresh-token"); + when(jwtProperties.refreshExpirationMs()).thenReturn(604800000L); + when(refreshTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + RefreshToken result = refreshTokenService.generate(user); + + verify(refreshTokenRepository).deleteByUser(user); + ArgumentCaptor captor = ArgumentCaptor.forClass(RefreshToken.class); + verify(refreshTokenRepository).save(captor.capture()); + assertThat(captor.getValue().getUser()).isEqualTo(user); + assertThat(captor.getValue().getToken()).isEqualTo("raw-refresh-token"); + assertThat(captor.getValue().getExpiresAt()).isAfter(Instant.now()); + assertThat(result.getToken()).isEqualTo("raw-refresh-token"); + } + + @Test + void _02_ShouldReturnToken_WhenValidAndNotExpired() { + RefreshToken token = RefreshToken.builder() + .token("raw-refresh-token").user(user) + .expiresAt(Instant.now().plus(7, ChronoUnit.DAYS)) + .build(); + when(refreshTokenRepository.findByToken("raw-refresh-token")).thenReturn(Optional.of(token)); + + RefreshToken result = refreshTokenService.validate("raw-refresh-token"); + + assertThat(result).isEqualTo(token); + } + + @Test + void _03_ShouldThrowInvalidTokenException_WhenTokenNotFound() { + when(refreshTokenRepository.findByToken(anyString())).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> refreshTokenService.validate("missing-token")) + .isInstanceOf(InvalidTokenException.class); + } + + @Test + void _04_ShouldThrowTokenExpiredException_WhenTokenIsExpired() { + RefreshToken expiredToken = RefreshToken.builder() + .token("old-refresh-token").user(user) + .expiresAt(Instant.now().minus(1, ChronoUnit.DAYS)) + .build(); + when(refreshTokenRepository.findByToken("old-refresh-token")).thenReturn(Optional.of(expiredToken)); + + assertThatThrownBy(() -> refreshTokenService.validate("old-refresh-token")) + .isInstanceOf(TokenExpiredException.class); + } + + @Test + void _05_ShouldDelegateToRepository_WhenDeleteByUserCalled() { + refreshTokenService.deleteByUser(user); + + verify(refreshTokenRepository).deleteByUser(user); + } +} From c59c3b626149197a56aec971d8c324de0a999d69 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:06:16 +0400 Subject: [PATCH 52/85] feat(scheduler): purge expired refresh tokens daily Adds purgeExpiredRefreshTokens() to TokenCleanupScheduler alongside the existing purges for activation, password-reset, and blacklisted tokens. Runs at 3 AM daily; a failure on this table does not affect the other three (each method has its own @Transactional). --- .../scheduler/TokenCleanupScheduler.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/scheduler/TokenCleanupScheduler.java b/src/main/java/com/edgareldy/springboottutorial/scheduler/TokenCleanupScheduler.java index 1317d63..808545d 100644 --- a/src/main/java/com/edgareldy/springboottutorial/scheduler/TokenCleanupScheduler.java +++ b/src/main/java/com/edgareldy/springboottutorial/scheduler/TokenCleanupScheduler.java @@ -3,6 +3,7 @@ import com.edgareldy.springboottutorial.repository.ActivationTokenRepository; import com.edgareldy.springboottutorial.repository.BlacklistedTokenRepository; import com.edgareldy.springboottutorial.repository.PasswordResetTokenRepository; +import com.edgareldy.springboottutorial.repository.RefreshTokenRepository; import java.time.Instant; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; @@ -32,6 +33,7 @@ public class TokenCleanupScheduler { private final ActivationTokenRepository activationTokenRepository; private final PasswordResetTokenRepository passwordResetTokenRepository; private final BlacklistedTokenRepository blacklistedTokenRepository; + private final RefreshTokenRepository refreshTokenRepository; @Scheduled(cron = DAILY_AT_3AM) @Transactional @@ -53,4 +55,11 @@ public void purgeExpiredBlacklistedTokens() { blacklistedTokenRepository.deleteAllByExpiresAtBefore(Instant.now()); log.info("Purged expired blacklisted tokens"); } + + @Scheduled(cron = DAILY_AT_3AM) + @Transactional + public void purgeExpiredRefreshTokens() { + refreshTokenRepository.deleteAllByExpiresAtBefore(Instant.now()); + log.info("Purged expired refresh tokens"); + } } From b32671025c797bad75bc4b279e9173ad97f5c029 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:11:37 +0400 Subject: [PATCH 53/85] feat(service): guard forgotPassword against disabled or locked accounts Silently ignores the reset request when the account is not yet enabled or is locked, the same way the ifPresent block already ignores unknown emails. No error is surfaced to the caller so the response is indistinguishable from the success case - this prevents email-enumeration on account status. --- .../springboottutorial/service/impl/UserServiceImpl.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java b/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java index 4654cc3..b6727f2 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java @@ -129,6 +129,9 @@ public UserResponse activate(String token) { @Transactional public void forgotPassword(String email) { appUserRepository.findByEmailIgnoreCase(email).ifPresent(user -> { + if (!user.isEnabled() || user.isAccountLocked()) { + return; + } String token = passwordResetTokenService.generate(user); emailService.sendPasswordResetEmail(user, token); }); From f21be973027b01240ff9bf6bcfba080807ea1d83 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:11:58 +0400 Subject: [PATCH 54/85] test(service): cover forgotPassword guard for disabled and locked accounts Two new tests (_10b_, _10c_) verify that the password reset email is never sent when the matched account is disabled or locked, asserting that neither passwordResetTokenService.generate() nor emailService.sendPasswordResetEmail() are called in those cases. --- .../service/impl/UserServiceImplTest.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java b/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java index ca78d8e..b297edc 100644 --- a/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java @@ -202,6 +202,34 @@ void _10_ShouldDoNothing_WhenForgotPasswordEmailUnknown() { verify(emailService, never()).sendPasswordResetEmail(any(), any()); } + @Test + void _10b_ShouldSkipResetEmail_WhenAccountNotEnabled() { + AppUser disabled = AppUser.builder() + .id(2L).username("dis").email("dis@example.com").password("hashed") + .enabled(false).accountLocked(false).roles(Set.of(userRole)).build(); + when(appUserRepository.findByEmailIgnoreCase("dis@example.com")) + .thenReturn(Optional.of(disabled)); + + userService.forgotPassword("dis@example.com"); + + verify(passwordResetTokenService, never()).generate(any()); + verify(emailService, never()).sendPasswordResetEmail(any(), any()); + } + + @Test + void _10c_ShouldSkipResetEmail_WhenAccountLocked() { + AppUser locked = AppUser.builder() + .id(3L).username("lck").email("lck@example.com").password("hashed") + .enabled(true).accountLocked(true).roles(Set.of(userRole)).build(); + when(appUserRepository.findByEmailIgnoreCase("lck@example.com")) + .thenReturn(Optional.of(locked)); + + userService.forgotPassword("lck@example.com"); + + verify(passwordResetTokenService, never()).generate(any()); + verify(emailService, never()).sendPasswordResetEmail(any(), any()); + } + @Test void _11_ShouldReHashPassword_WhenResetTokenValid() { when(passwordResetTokenService.validateAndConsume("reset-tok")).thenReturn(appUser); From 6f921d8dfc618e4f12aa42b3233088238060c9f9 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:12:34 +0400 Subject: [PATCH 55/85] feat(service): add getUserByToken to ActivationTokenService New method retrieves the AppUser associated with a raw activation token without consuming the token, so UserServiceImpl.activate() can re-send the activation email after catching TokenExpiredException. --- .../service/ActivationTokenService.java | 2 ++ .../service/impl/ActivationTokenServiceImpl.java | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/service/ActivationTokenService.java b/src/main/java/com/edgareldy/springboottutorial/service/ActivationTokenService.java index 94f9c49..806d63d 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/ActivationTokenService.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/ActivationTokenService.java @@ -18,4 +18,6 @@ public interface ActivationTokenService { String generate(AppUser user); AppUser validate(String token); + + AppUser getUserByToken(String token); } diff --git a/src/main/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImpl.java b/src/main/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImpl.java index 84630b6..69e35b3 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImpl.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImpl.java @@ -47,6 +47,14 @@ public String generate(AppUser user) { return rawToken; } + @Override + @Transactional(readOnly = true) + public AppUser getUserByToken(String token) { + return activationTokenRepository.findByToken(token) + .orElseThrow(() -> new InvalidTokenException("Invalid activation token")) + .getUser(); + } + @Override @Transactional public AppUser validate(String token) { From 6133be69adc660596dd3e05ac891dae82fa3488d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:12:39 +0400 Subject: [PATCH 56/85] feat(service): auto-resend activation email on expired token in activate() When activationTokenService.validate() throws TokenExpiredException, we recover the user via getUserByToken(), generate a new token (which also purges the expired one), and send a fresh activation email. The exception is then re-thrown so the caller still receives a 400 response - the user must click the new link before they can log in. --- .../service/impl/UserServiceImpl.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java b/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java index b6727f2..cb1bceb 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java @@ -8,6 +8,7 @@ import com.edgareldy.springboottutorial.entity.user.AppUser; import com.edgareldy.springboottutorial.exception.BusinessRuleException; import com.edgareldy.springboottutorial.exception.ResourceNotFoundException; +import com.edgareldy.springboottutorial.exception.TokenExpiredException; import com.edgareldy.springboottutorial.mapper.AppUserMapper; import com.edgareldy.springboottutorial.repository.AppUserRepository; import com.edgareldy.springboottutorial.repository.RoleRepository; @@ -120,9 +121,19 @@ public UserResponse updateProfile(Long id, UpdateProfileRequest request) { @Override @Transactional public UserResponse activate(String token) { - AppUser user = activationTokenService.validate(token); - user.setEnabled(true); - return appUserMapper.toResponse(appUserRepository.save(user)); + try { + AppUser user = activationTokenService.validate(token); + user.setEnabled(true); + return appUserMapper.toResponse(appUserRepository.save(user)); + } catch (TokenExpiredException e) { + // Token expired: generate a fresh one and re-send the email, then + // rethrow so the caller still gets an error response (the user must + // click the new link before they can log in). + AppUser user = activationTokenService.getUserByToken(token); + String newToken = activationTokenService.generate(user); + emailService.sendActivationEmail(user, newToken); + throw e; + } } @Override From fcc346f4f8541eeb664cd5d34d1de0fb7d70b734 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:12:53 +0400 Subject: [PATCH 57/85] test(service): cover getUserByToken happy path and missing-token error --- .../impl/ActivationTokenServiceImplTest.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/test/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImplTest.java b/src/test/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImplTest.java index 1dbe4a4..944d803 100644 --- a/src/test/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImplTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/ActivationTokenServiceImplTest.java @@ -118,4 +118,23 @@ void _05_ShouldThrowTokenExpiredException_WhenTokenIsExpired() { assertThatThrownBy(() -> activationTokenService.validate("raw-token")) .isInstanceOf(TokenExpiredException.class); } + + @Test + void _06_ShouldReturnUser_WhenGetUserByTokenCalled() { + ActivationToken activationToken = ActivationToken.builder() + .id(1L).user(user).token("raw-token") + .createdAt(Instant.now()).expiresAt(Instant.now().plus(1, ChronoUnit.HOURS)) + .build(); + when(activationTokenRepository.findByToken("raw-token")).thenReturn(Optional.of(activationToken)); + + assertThat(activationTokenService.getUserByToken("raw-token")).isEqualTo(user); + } + + @Test + void _07_ShouldThrowInvalidTokenException_WhenGetUserByTokenCalledWithMissingToken() { + when(activationTokenRepository.findByToken("missing")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> activationTokenService.getUserByToken("missing")) + .isInstanceOf(InvalidTokenException.class); + } } From fa16cc4e438fc052315a79adf4c91bc2bb2929f5 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:13:14 +0400 Subject: [PATCH 58/85] test(service): cover auto-resend activation email on expired token --- .../service/impl/UserServiceImplTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java b/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java index b297edc..e433883 100644 --- a/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java @@ -17,6 +17,7 @@ import com.edgareldy.springboottutorial.entity.user.AppUser; import com.edgareldy.springboottutorial.exception.BusinessRuleException; import com.edgareldy.springboottutorial.exception.ResourceNotFoundException; +import com.edgareldy.springboottutorial.exception.TokenExpiredException; import com.edgareldy.springboottutorial.mapper.AppUserMapper; import com.edgareldy.springboottutorial.repository.AppUserRepository; import com.edgareldy.springboottutorial.repository.RoleRepository; @@ -180,6 +181,21 @@ void _08_ShouldEnableUser_WhenActivationTokenValid() { assertThat(appUser.isEnabled()).isTrue(); } + @Test + void _08b_ShouldResendActivationEmailAndRethrow_WhenTokenExpired() { + when(activationTokenService.validate("expired-tok")) + .thenThrow(new TokenExpiredException("Activation token expired")); + when(activationTokenService.getUserByToken("expired-tok")).thenReturn(appUser); + when(activationTokenService.generate(appUser)).thenReturn("new-tok"); + + assertThatThrownBy(() -> userService.activate("expired-tok")) + .isInstanceOf(TokenExpiredException.class); + + verify(activationTokenService).getUserByToken("expired-tok"); + verify(activationTokenService).generate(appUser); + verify(emailService).sendActivationEmail(appUser, "new-tok"); + } + @Test void _09_ShouldSendResetEmail_WhenEmailExists() { when(appUserRepository.findByEmailIgnoreCase("ada@example.com")) From 216aa8e5b415f4ad382a9e7d17457d4ef7d41455 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:13:47 +0400 Subject: [PATCH 59/85] feat(service): add removeRoleFromUser to UserService interface --- .../com/edgareldy/springboottutorial/service/UserService.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/service/UserService.java b/src/main/java/com/edgareldy/springboottutorial/service/UserService.java index 5c3be57..f227f12 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/UserService.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/UserService.java @@ -23,6 +23,8 @@ public interface UserService { UserResponse assignRoles(Long userId, AssignRolesRequest request); + UserResponse removeRoleFromUser(Long userId, Long roleId); + void delete(Long id); UserResponse lock(Long id, String currentUsername); From 3b9c638d7b5b63feee81c5a9f1eaeb5cc3f49743 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:13:51 +0400 Subject: [PATCH 60/85] feat(service): implement removeRoleFromUser in UserServiceImpl Removes the role matching roleId from the user's roles set. Throws BusinessRuleException (422) if the user does not have that role, so the caller gets a meaningful error instead of a silent no-op. --- .../service/impl/UserServiceImpl.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java b/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java index cb1bceb..16ea61e 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java @@ -73,6 +73,17 @@ public UserResponse assignRoles(Long userId, AssignRolesRequest request) { return appUserMapper.toResponse(appUserRepository.save(user)); } + @Override + @Transactional + public UserResponse removeRoleFromUser(Long userId, Long roleId) { + AppUser user = getUserOrThrow(userId); + boolean removed = user.getRoles().removeIf(r -> r.getId().equals(roleId)); + if (!removed) { + throw new BusinessRuleException("User does not have role with id " + roleId); + } + return appUserMapper.toResponse(appUserRepository.save(user)); + } + @Override @Transactional public void delete(Long id) { From 3aaa4cdda6aa7b7f407a33beecbcf4bf6a3c87af Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:13:55 +0400 Subject: [PATCH 61/85] feat(controller): add DELETE /api/v1/users/{id}/roles/{roleId} endpoint ADMIN-only endpoint (inherited from class-level @PreAuthorize) to remove a specific role from a user. Returns 200 with the updated UserResponse on success, 422 if the user does not have that role. --- .../springboottutorial/controller/UserController.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/controller/UserController.java b/src/main/java/com/edgareldy/springboottutorial/controller/UserController.java index 849eb5f..c84576b 100644 --- a/src/main/java/com/edgareldy/springboottutorial/controller/UserController.java +++ b/src/main/java/com/edgareldy/springboottutorial/controller/UserController.java @@ -65,6 +65,14 @@ public ResponseEntity> assignRoles( return ResponseEntity.ok(ApiResponse.success(userService.assignRoles(id, request), "Roles assigned successfully")); } + @DeleteMapping("/{id}/roles/{roleId}") + public ResponseEntity> removeRole( + @PathVariable Long id, + @PathVariable Long roleId) { + return ResponseEntity.ok(ApiResponse.success( + userService.removeRoleFromUser(id, roleId), "Role removed successfully")); + } + @DeleteMapping("/{id}") public ResponseEntity> delete(@PathVariable Long id) { userService.delete(id); From 8eb7f445c48994c3e3e1af58e36f8eba2bdba69e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:14:10 +0400 Subject: [PATCH 62/85] test(controller): cover DELETE /users/{id}/roles/{roleId} - success and 422 --- .../controller/UserControllerTest.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/test/java/com/edgareldy/springboottutorial/controller/UserControllerTest.java b/src/test/java/com/edgareldy/springboottutorial/controller/UserControllerTest.java index c1c05fc..f5e704c 100644 --- a/src/test/java/com/edgareldy/springboottutorial/controller/UserControllerTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/controller/UserControllerTest.java @@ -132,6 +132,26 @@ void _06_ShouldReturn422_WhenSomeRoleIdsInvalid() throws Exception { .andExpect(status().isUnprocessableEntity()); } + @Test + void _06b_ShouldReturn200_WhenRemovingRoleFromUser() throws Exception { + UserResponse updated = new UserResponse(1L, "Ada", "Lovelace", "ada", "ada@example.com", true, false, + List.of()); + when(userService.removeRoleFromUser(1L, 1L)).thenReturn(updated); + + mockMvc.perform(delete("/api/v1/users/1/roles/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + + @Test + void _06c_ShouldReturn422_WhenRoleNotAssignedToUser() throws Exception { + when(userService.removeRoleFromUser(1L, 99L)) + .thenThrow(new BusinessRuleException("User does not have role with id 99")); + + mockMvc.perform(delete("/api/v1/users/1/roles/99")) + .andExpect(status().isUnprocessableEntity()); + } + @Test void _07_ShouldReturn200_WhenDeletingUser() throws Exception { mockMvc.perform(delete("/api/v1/users/1")) From 3a9c874e03191e97023a251edd59d60494b2d34e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:14:30 +0400 Subject: [PATCH 63/85] test(service): cover removeRoleFromUser - success and role-not-found 422 --- .../service/impl/UserServiceImplTest.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java b/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java index e433883..44fe6b5 100644 --- a/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java @@ -24,6 +24,7 @@ import com.edgareldy.springboottutorial.service.ActivationTokenService; import com.edgareldy.springboottutorial.service.EmailService; import com.edgareldy.springboottutorial.service.PasswordResetTokenService; +import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; @@ -147,6 +148,34 @@ void _05_ShouldThrowBusinessRule_WhenSomeRoleIdsInvalid() { verify(appUserRepository, never()).save(any()); } + @Test + void _05b_ShouldRemoveRole_WhenUserHasIt() { + AppUser userWithRole = AppUser.builder() + .id(1L).username("ada").email("ada@example.com").password("hashed") + .enabled(true).roles(new HashSet<>(Set.of(userRole))).build(); + UserResponse updatedResponse = new UserResponse(1L, "Ada", "Lovelace", "ada", "ada@example.com", true, false, + List.of()); + when(appUserRepository.findById(1L)).thenReturn(Optional.of(userWithRole)); + when(appUserRepository.save(userWithRole)).thenReturn(userWithRole); + when(appUserMapper.toResponse(userWithRole)).thenReturn(updatedResponse); + + assertThat(userService.removeRoleFromUser(1L, 1L)).isEqualTo(updatedResponse); + assertThat(userWithRole.getRoles()).isEmpty(); + } + + @Test + void _05c_ShouldThrowBusinessRule_WhenUserDoesNotHaveRole() { + AppUser userWithRole = AppUser.builder() + .id(1L).username("ada").email("ada@example.com").password("hashed") + .enabled(true).roles(new HashSet<>(Set.of(userRole))).build(); + when(appUserRepository.findById(1L)).thenReturn(Optional.of(userWithRole)); + + assertThatThrownBy(() -> userService.removeRoleFromUser(1L, 99L)) + .isInstanceOf(BusinessRuleException.class); + + verify(appUserRepository, never()).save(any()); + } + @Test void _06_ShouldDeleteUser_WhenExists() { when(appUserRepository.findById(1L)).thenReturn(Optional.of(appUser)); From f1455930dca16493f3787f931a86ab23c1e34abf Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:14:41 +0400 Subject: [PATCH 64/85] fix(service): make assignRoles additive instead of replacing the full set user.setRoles(roles) discarded every existing role not listed in the request. user.getRoles().addAll(roles) preserves roles the user already has and only adds the new ones. To replace the full set, callers should call assignRoles for the desired set and use removeRoleFromUser for individual removals. --- .../springboottutorial/service/impl/UserServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java b/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java index 16ea61e..2deb3f0 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/impl/UserServiceImpl.java @@ -69,7 +69,7 @@ public UserResponse assignRoles(Long userId, AssignRolesRequest request) { if (roles.size() != request.roleIds().size()) { throw new BusinessRuleException("One or more role IDs are invalid"); } - user.setRoles(roles); + user.getRoles().addAll(roles); return appUserMapper.toResponse(appUserRepository.save(user)); } From 2f16183add7f2d7e4f58cc246c10539fd7034279 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:15:17 +0400 Subject: [PATCH 65/85] test(service): use mutable HashSet in setUp and add additive assignRoles test Replaced .roles(Set.of(userRole)) with .build() + getRoles().add(userRole) so the roles collection is mutable. This is required by the additive assignRoles fix (B3): calling addAll on an immutable Set.of() would throw UnsupportedOperationException at test time (Hibernate always provides a mutable collection at runtime, so production code is unaffected). Added _04b_ to verify that assignRoles preserves existing roles and only adds new ones, contrasting with the previous replace-all behavior. --- .../springboottutorial/service/impl/UserServiceImplTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java b/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java index 44fe6b5..973b8db 100644 --- a/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java @@ -85,7 +85,8 @@ void setUp() { userRole = Role.builder().id(1L).roleName("USER").build(); appUser = AppUser.builder() .id(1L).username("ada").email("ada@example.com").password("hashed") - .enabled(false).roles(Set.of(userRole)).build(); + .enabled(false).build(); + appUser.getRoles().add(userRole); userResponse = new UserResponse(1L, "Ada", "Lovelace", "ada", "ada@example.com", false, false, List.of(new RoleResponse(1L, "USER", List.of()))); } From b72601d3ca2f2cbee5c41ea200e24370cd32db50 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:15:30 +0400 Subject: [PATCH 66/85] test(service): add _04b_ asserting assignRoles preserves pre-existing roles --- .../service/impl/UserServiceImplTest.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java b/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java index 973b8db..1f5d885 100644 --- a/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java @@ -137,6 +137,21 @@ void _04_ShouldAssignRoles_WhenAllRoleIdsValid() { assertThat(userService.assignRoles(1L, request)).isEqualTo(savedResponse); } + @Test + void _04b_ShouldPreserveExistingRoles_WhenAssigningNewOnes() { + Role adminRole = Role.builder().id(2L).roleName("ADMIN").build(); + AssignRolesRequest request = new AssignRolesRequest(Set.of(2L)); + when(appUserRepository.findById(1L)).thenReturn(Optional.of(appUser)); + when(roleRepository.findAllById(Set.of(2L))).thenReturn(List.of(adminRole)); + when(appUserRepository.save(appUser)).thenReturn(appUser); + when(appUserMapper.toResponse(appUser)).thenReturn(userResponse); + + userService.assignRoles(1L, request); + + // existing userRole (id=1) must still be present alongside the newly added ADMIN + assertThat(appUser.getRoles()).contains(userRole, adminRole); + } + @Test void _05_ShouldThrowBusinessRule_WhenSomeRoleIdsInvalid() { AssignRolesRequest request = new AssignRolesRequest(Set.of(1L, 99L)); From 9f865cebfed1539cb016ab1c1ba3f81ad0063202 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:31:36 +0400 Subject: [PATCH 67/85] fix(test): use email instead of username in LoginRequest for _14_ and _15_ LoginRequest.email is annotated @Email, so passing a bare username ("hopper", "babbage") triggered Bean Validation and returned 400 before the AuthenticationManager was ever reached. The fix passes the full email address, matching AuthServiceImpl.login() which looks up accounts by email. --- .../controller/SecurityAuthorizationTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java b/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java index a875208..8c61810 100644 --- a/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java @@ -152,7 +152,7 @@ void _14_ShouldAuthenticateSubsequentRequests_WhenLoginIssuesToken() throws Exce .content(objectMapper.writeValueAsString(register))) .andExpect(status().isCreated()); - LoginRequest login = new LoginRequest("hopper", "password123"); + LoginRequest login = new LoginRequest("hopper@example.com", "password123"); MvcResult loginResult = mockMvc.perform(post("/api/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(login))) @@ -173,7 +173,7 @@ void _15_ShouldRejectLogin_WhenPasswordIsWrong() throws Exception { .content(objectMapper.writeValueAsString(register))) .andExpect(status().isCreated()); - LoginRequest wrongPassword = new LoginRequest("babbage", "not-the-password"); + LoginRequest wrongPassword = new LoginRequest("babbage@example.com", "not-the-password"); mockMvc.perform(post("/api/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(wrongPassword))) From 9836b7d55ee1d21fd5c153eb46beb9bb745ac0fb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:37:25 +0400 Subject: [PATCH 68/85] fix(test): enable babbage before wrong-password login in _15_ DisabledException now maps to 403 (added this session). Registering a user leaves enabled=false, so attempting login on a disabled account throws DisabledException before the password is ever checked. Added enableUser() so the authentication reaches BadCredentialsException -> 401 as the test expects. --- .../controller/SecurityAuthorizationTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java b/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java index b64c5ee..f4b0e2b 100644 --- a/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java @@ -188,6 +188,8 @@ void _15_ShouldRejectLogin_WhenPasswordIsWrong() throws Exception { .content(objectMapper.writeValueAsString(register))) .andExpect(status().isCreated()); + enableUser("babbage"); + LoginRequest wrongPassword = new LoginRequest("babbage@example.com", "not-the-password"); mockMvc.perform(post("/api/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) From 4fe35c5ece33f2bf903f23431d5bc20a1a06a6d3 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:37:29 +0400 Subject: [PATCH 69/85] fix(test): set createdAt in PasswordResetTokenRepositoryTest builders V11 migration added created_at NOT NULL to password_reset_tokens. PasswordResetToken now extends AuditableToken which carries that column, but the repository test built tokens without setting createdAt, triggering a NOT NULL constraint violation at save time. --- .../repository/PasswordResetTokenRepositoryTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/test/java/com/edgareldy/springboottutorial/repository/PasswordResetTokenRepositoryTest.java b/src/test/java/com/edgareldy/springboottutorial/repository/PasswordResetTokenRepositoryTest.java index c4f7269..00d207e 100644 --- a/src/test/java/com/edgareldy/springboottutorial/repository/PasswordResetTokenRepositoryTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/repository/PasswordResetTokenRepositoryTest.java @@ -47,6 +47,7 @@ void setUp() { void _01_ShouldReturnMatchingToken_WhenFindingByToken() { passwordResetTokenRepository.save(PasswordResetToken.builder() .user(user).token("reset-token").type("PASSWORD_RESET") + .createdAt(Instant.now()) .expiryDate(Instant.now().plus(1, ChronoUnit.HOURS)) .build()); @@ -66,10 +67,12 @@ void _02_ShouldReturnEmpty_WhenTokenIsMissing() { void _03_ShouldRemoveOnlyExpiredTokens_WhenDeletingByExpiryDateBefore() { PasswordResetToken expired = passwordResetTokenRepository.save(PasswordResetToken.builder() .user(user).token("expired-token").type("PASSWORD_RESET") + .createdAt(Instant.now()) .expiryDate(Instant.now().minus(1, ChronoUnit.HOURS)) .build()); PasswordResetToken active = passwordResetTokenRepository.save(PasswordResetToken.builder() .user(user).token("active-token").type("PASSWORD_RESET") + .createdAt(Instant.now()) .expiryDate(Instant.now().plus(1, ChronoUnit.HOURS)) .build()); From 0d63f03fe4d2a3fb8b9ae825047194ed18d278d2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:43:44 +0400 Subject: [PATCH 70/85] fix(test): replace undefined enableUser() with inline lambda in _18_ The merge from feature/token-management brought in enableUser("babbage") inside _18_ShouldReturn401_WhenPasswordIsWrong, but that helper method exists only on the token-management version of the file. On feature/user-management the same pattern is done inline (see _14_, _19_, _20_). Replaced with the equivalent appUserRepository lambda so the class compiles. --- .../controller/SecurityAuthorizationTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java b/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java index 48620ce..5110e19 100644 --- a/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java @@ -203,7 +203,10 @@ void _18_ShouldReturn401_WhenPasswordIsWrong() throws Exception { .content(objectMapper.writeValueAsString(register))) .andExpect(status().isCreated()); - enableUser("babbage"); + appUserRepository.findByUsername("babbage").ifPresent(u -> { + u.setEnabled(true); + appUserRepository.saveAndFlush(u); + }); LoginRequest wrongPassword = new LoginRequest("babbage@example.com", "not-the-password"); mockMvc.perform(post("/api/v1/auth/login") From 7762f4d38a51591cdfe0be2141413814482e3d1b Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 12:47:12 +0400 Subject: [PATCH 71/85] fix(test): enable appUser before forgotPassword in _09_ The setUp() creates appUser with enabled=false (intentional, for _08_ which tests account activation). The forgotPassword() guard added in item 05 skips disabled accounts, so _09_ never reached the email send. Fixed by setting enabled=true at the top of _09_ only. --- .../springboottutorial/service/impl/UserServiceImplTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java b/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java index 1f5d885..c710c9a 100644 --- a/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java @@ -243,6 +243,7 @@ void _08b_ShouldResendActivationEmailAndRethrow_WhenTokenExpired() { @Test void _09_ShouldSendResetEmail_WhenEmailExists() { + appUser.setEnabled(true); when(appUserRepository.findByEmailIgnoreCase("ada@example.com")) .thenReturn(Optional.of(appUser)); when(passwordResetTokenService.generate(appUser)).thenReturn("reset-token"); From a560a7309beaf9ef7d537e4bf7b5a968c383e1f1 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 18:51:51 +0400 Subject: [PATCH 72/85] refactor(dto): rename token to access_token and drop username in AuthResponse Renames the JSON field from "token" to "access_token" (OAuth 2.0 naming convention) and removes the username field. The username is already embedded as the "sub" claim inside the JWT, so returning it separately is redundant. The field is renamed accessToken in Java to match, with an explicit @JsonProperty("access_token") to keep the JSON contract. --- .../dto/auth/AuthResponse.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/dto/auth/AuthResponse.java b/src/main/java/com/edgareldy/springboottutorial/dto/auth/AuthResponse.java index 308ed7c..13b60ca 100644 --- a/src/main/java/com/edgareldy/springboottutorial/dto/auth/AuthResponse.java +++ b/src/main/java/com/edgareldy/springboottutorial/dto/auth/AuthResponse.java @@ -3,11 +3,12 @@ import com.fasterxml.jackson.annotation.JsonProperty; /** - * Payload returned by {@code POST /api/v1/auth/login}: the JWT to send back - * as a {@code Bearer} token on subsequent requests, plus enough profile - * information for the client to avoid an extra call to {@code /auth/me}. - * The JSON contract is snake_case, decoupled from the Java field's own - * camelCase name via an explicit {@link JsonProperty} annotation. + * Payload returned by {@code POST /api/v1/auth/login}. The JSON field is + * {@code access_token} (via {@link JsonProperty}) to distinguish it clearly + * from the refresh token added in a later iteration, and to follow the + * OAuth 2.0 naming convention. The {@code username} field is omitted: it is + * already embedded as the {@code sub} claim inside the JWT itself, so the + * client can decode the token rather than carry a redundant field. *

* Created edgar.muhamyangabo on 7/8/26 * Author : edgar.muhamyangabo @@ -15,12 +16,11 @@ * Project : spring-boot-tutorial */ public record AuthResponse( - String token, + @JsonProperty("access_token") + String accessToken, @JsonProperty("token_type") - String tokenType, - - String username + String tokenType ) { // Overrides the compiler-generated toString(): LoggingAspect logs every @@ -29,6 +29,6 @@ public record AuthResponse( // a live bearer credential, in the application logs. @Override public String toString() { - return "AuthResponse[token=***, tokenType=%s, username=%s]".formatted(tokenType, username); + return "AuthResponse[accessToken=***, tokenType=%s]".formatted(tokenType); } } From f645f13213f4adc68362cf69e6b8d3d43e5d1cf1 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 18:52:00 +0400 Subject: [PATCH 73/85] refactor(auth): update AuthServiceImpl.login() for new 2-field AuthResponse --- .../springboottutorial/service/impl/AuthServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java b/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java index 5ab198c..5851084 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java @@ -71,7 +71,7 @@ public AuthResponse login(LoginRequest request) { authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(appUser.getUsername(), request.password())); String token = jwtService.generateToken(appUser.getUsername()); - return new AuthResponse(token, BEARER_TOKEN_TYPE, appUser.getUsername()); + return new AuthResponse(token, BEARER_TOKEN_TYPE); } @Override From 7fd6637cac8f7871dd1e6c51aae115d4f5249afd Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 18:52:10 +0400 Subject: [PATCH 74/85] test(auth): update AuthResponse constructor in AuthServiceImplTest to 2 args --- .../springboottutorial/service/impl/AuthServiceImplTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java b/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java index 19ee090..f56ed1d 100644 --- a/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java @@ -120,7 +120,7 @@ void _04_ShouldReturnToken_WhenCredentialsValid() { AuthResponse response = authService.login(request); - assertThat(response).isEqualTo(new AuthResponse("signed-token", "Bearer", "ada")); + assertThat(response).isEqualTo(new AuthResponse("signed-token", "Bearer")); verify(authenticationManager).authenticate(new UsernamePasswordAuthenticationToken("ada", "password123")); } From ac8aa4412d14dcb2ddd99790fbae836500b43444 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 18:52:21 +0400 Subject: [PATCH 75/85] test(auth): update AuthControllerTest for 2-field AuthResponse and access_token JSON path --- .../springboottutorial/controller/AuthControllerTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java b/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java index 1dc799c..bdc28a7 100644 --- a/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java @@ -103,13 +103,13 @@ void _03_ShouldReturn422_WhenUsernameAlreadyUsed() throws Exception { @Test void _04_ShouldReturn200_WhenCredentialsValid() throws Exception { - when(authService.login(any())).thenReturn(new AuthResponse("signed-token", "Bearer", "ada")); + when(authService.login(any())).thenReturn(new AuthResponse("signed-token", "Bearer")); mockMvc.perform(post("/api/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(new LoginRequest("ada@example.com", "password123")))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.token").value("signed-token")) + .andExpect(jsonPath("$.data.access_token").value("signed-token")) .andExpect(jsonPath("$.data.token_type").value("Bearer")); } From 5128fc3f4c843557005c8fd816085a519b7036fc Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 18:52:31 +0400 Subject: [PATCH 76/85] test(auth): update SecurityAuthorizationTest to read access_token JSON field --- .../controller/SecurityAuthorizationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java b/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java index 8c61810..680d8b9 100644 --- a/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java @@ -159,7 +159,7 @@ void _14_ShouldAuthenticateSubsequentRequests_WhenLoginIssuesToken() throws Exce .andExpect(status().isOk()) .andReturn(); String token = objectMapper.readTree(loginResult.getResponse().getContentAsString()) - .path("data").path("token").asText(); + .path("data").path("access_token").asText(); mockMvc.perform(get("/api/v1/customers").header("Authorization", "Bearer " + token)) .andExpect(status().isOk()); From a2ab6397465f517db89c049ff6a967a485989b7a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 18:55:44 +0400 Subject: [PATCH 77/85] test(auth): update AuthDtoMaskingTest for 2-field AuthResponse (no username) --- .../springboottutorial/dto/auth/AuthDtoMaskingTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/dto/auth/AuthDtoMaskingTest.java b/src/test/java/com/edgareldy/springboottutorial/dto/auth/AuthDtoMaskingTest.java index 6372f1a..60287ca 100644 --- a/src/test/java/com/edgareldy/springboottutorial/dto/auth/AuthDtoMaskingTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/dto/auth/AuthDtoMaskingTest.java @@ -34,8 +34,8 @@ void _02_ShouldMaskPassword_WhenLoginRequestPrinted() { @Test void _03_ShouldMaskToken_WhenAuthResponsePrinted() { - String result = new AuthResponse("signed.jwt.token", "Bearer", "ada").toString(); + String result = new AuthResponse("signed.jwt.token", "Bearer").toString(); - assertThat(result).contains("Bearer", "ada").doesNotContain("signed.jwt.token"); + assertThat(result).contains("Bearer").doesNotContain("signed.jwt.token"); } } From 71c9f4046270aad3124f1088285c2c2529d4be1a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 18:59:20 +0400 Subject: [PATCH 78/85] feat(security): expose getExpirationMs() on JwtService for expires_in in AuthResponse --- .../com/edgareldy/springboottutorial/security/JwtService.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/edgareldy/springboottutorial/security/JwtService.java b/src/main/java/com/edgareldy/springboottutorial/security/JwtService.java index c201ff5..af56cbd 100644 --- a/src/main/java/com/edgareldy/springboottutorial/security/JwtService.java +++ b/src/main/java/com/edgareldy/springboottutorial/security/JwtService.java @@ -85,6 +85,10 @@ public boolean isRefreshToken(String token) { } } + public long getExpirationMs() { + return expirationMs; + } + public Map extractAllClaims(String token) { return parseClaims(token); } From 093a20d2b6dd189bd39e8720b09cfa420a860579 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 18:59:38 +0400 Subject: [PATCH 79/85] feat(dto): add expires_in field to AuthResponse Adds expiresIn (JSON: expires_in) as the access token lifetime in seconds. The client uses this value to schedule a proactive token refresh before the access token expires, following the OAuth 2.0 token response convention. The value is computed by AuthServiceImpl from JwtService.getExpirationMs(). --- .../dto/auth/AuthResponse.java | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/dto/auth/AuthResponse.java b/src/main/java/com/edgareldy/springboottutorial/dto/auth/AuthResponse.java index b5ad024..637b46c 100644 --- a/src/main/java/com/edgareldy/springboottutorial/dto/auth/AuthResponse.java +++ b/src/main/java/com/edgareldy/springboottutorial/dto/auth/AuthResponse.java @@ -3,12 +3,14 @@ import com.fasterxml.jackson.annotation.JsonProperty; /** - * Payload returned by {@code POST /api/v1/auth/login}. The JSON field is - * {@code access_token} (via {@link JsonProperty}) to distinguish it clearly - * from the refresh token added in a later iteration, and to follow the - * OAuth 2.0 naming convention. The {@code username} field is omitted: it is - * already embedded as the {@code sub} claim inside the JWT itself, so the - * client can decode the token rather than carry a redundant field. + * Payload returned by {@code POST /api/v1/auth/login} and + * {@code POST /api/v1/auth/refresh-token}. Fields follow the OAuth 2.0 + * naming convention: {@code access_token}, {@code token_type}, + * {@code refresh_token}, and {@code expires_in} (access token lifetime in + * seconds). The client uses {@code expires_in} to schedule a proactive + * refresh before the access token expires, avoiding a failed request. + * {@code username} is omitted since it is already the {@code sub} claim + * inside the JWT itself. *

* Created edgar.muhamyangabo on 7/8/26 * Author : edgar.muhamyangabo @@ -23,7 +25,10 @@ public record AuthResponse( String tokenType, @JsonProperty("refresh_token") - String refreshToken + String refreshToken, + + @JsonProperty("expires_in") + long expiresIn ) { // Overrides the compiler-generated toString(): LoggingAspect logs every @@ -32,7 +37,7 @@ public record AuthResponse( // a live bearer credential, in the application logs. @Override public String toString() { - return "AuthResponse[accessToken=***, tokenType=%s, refreshToken=***]" - .formatted(tokenType); + return "AuthResponse[accessToken=***, tokenType=%s, refreshToken=***, expiresIn=%d]" + .formatted(tokenType, expiresIn); } } From 96e0a03f2604e3667f298aca87c7adceca71b97d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 18:59:47 +0400 Subject: [PATCH 80/85] feat(auth): pass expires_in to AuthResponse in login() and refresh() --- .../springboottutorial/service/impl/AuthServiceImpl.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java b/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java index 56cf0a5..d6efbae 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java @@ -81,7 +81,8 @@ public AuthResponse login(LoginRequest request) { new UsernamePasswordAuthenticationToken(appUser.getUsername(), request.password())); String accessToken = jwtService.generateToken(appUser.getUsername()); RefreshToken refreshToken = refreshTokenService.generate(appUser); - return new AuthResponse(accessToken, BEARER_TOKEN_TYPE, refreshToken.getToken()); + long expiresIn = jwtService.getExpirationMs() / 1000; + return new AuthResponse(accessToken, BEARER_TOKEN_TYPE, refreshToken.getToken(), expiresIn); } @Override @@ -91,7 +92,8 @@ public AuthResponse refresh(String rawRefreshToken) { AppUser appUser = refreshToken.getUser(); String newAccessToken = jwtService.generateToken(appUser.getUsername()); RefreshToken newRefreshToken = refreshTokenService.generate(appUser); - return new AuthResponse(newAccessToken, BEARER_TOKEN_TYPE, newRefreshToken.getToken()); + long expiresIn = jwtService.getExpirationMs() / 1000; + return new AuthResponse(newAccessToken, BEARER_TOKEN_TYPE, newRefreshToken.getToken(), expiresIn); } @Override From b837f0a9f77f160bf2b6d5853435bae6f12a9df7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 18:59:58 +0400 Subject: [PATCH 81/85] test(auth): stub getExpirationMs() and update AuthResponse to 4-field in AuthServiceImplTest --- .../springboottutorial/service/impl/AuthServiceImplTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java b/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java index bfe951b..20c984c 100644 --- a/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java @@ -118,11 +118,12 @@ void _04_ShouldReturnToken_WhenCredentialsValid() { .token("refresh-token").build(); when(appUserRepository.findByEmailIgnoreCase("ada@example.com")).thenReturn(Optional.of(appUser)); when(jwtService.generateToken("ada")).thenReturn("signed-token"); + when(jwtService.getExpirationMs()).thenReturn(3600000L); when(refreshTokenService.generate(appUser)).thenReturn(fakeRefreshToken); AuthResponse response = authService.login(request); - assertThat(response).isEqualTo(new AuthResponse("signed-token", "Bearer", "refresh-token")); + assertThat(response).isEqualTo(new AuthResponse("signed-token", "Bearer", "refresh-token", 3600L)); verify(authenticationManager).authenticate(new UsernamePasswordAuthenticationToken("ada", "password123")); verify(refreshTokenService).generate(appUser); } From 7f47eb2d1975fabed690885b3d105a9f6984216c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 19:00:10 +0400 Subject: [PATCH 82/85] test(auth): update AuthResponse to 4-field and assert expires_in in AuthControllerTest --- .../springboottutorial/controller/AuthControllerTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java b/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java index 57d4ef1..4785c53 100644 --- a/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java @@ -121,14 +121,15 @@ void _03_ShouldReturn400_WhenFirstNameBlank() throws Exception { @Test void _04_ShouldReturn200_WhenCredentialsValid() throws Exception { - when(authService.login(any())).thenReturn(new AuthResponse("signed-token", "Bearer", "refresh-token")); + when(authService.login(any())).thenReturn(new AuthResponse("signed-token", "Bearer", "refresh-token", 3600L)); mockMvc.perform(post("/api/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(new LoginRequest("ada@example.com", "password123")))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.access_token").value("signed-token")) - .andExpect(jsonPath("$.data.token_type").value("Bearer")); + .andExpect(jsonPath("$.data.token_type").value("Bearer")) + .andExpect(jsonPath("$.data.expires_in").value(3600)); } @Test @@ -267,7 +268,7 @@ void _17_ShouldReturn401_WhenTokenInfoCalledUnauthenticated() throws Exception { @Test void _18_ShouldReturn200_WhenRefreshTokenValid() throws Exception { when(authService.refresh("refresh-token")) - .thenReturn(new AuthResponse("new-access-token", "Bearer", "new-refresh-token")); + .thenReturn(new AuthResponse("new-access-token", "Bearer", "new-refresh-token", 3600L)); mockMvc.perform(post("/api/v1/auth/refresh-token") .header("Authorization", "Bearer refresh-token")) From fc5b5674e554b8b7bc21af02189775755c092059 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 19:00:18 +0400 Subject: [PATCH 83/85] test(auth): update AuthDtoMaskingTest for 4-field AuthResponse with expiresIn --- .../springboottutorial/dto/auth/AuthDtoMaskingTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/dto/auth/AuthDtoMaskingTest.java b/src/test/java/com/edgareldy/springboottutorial/dto/auth/AuthDtoMaskingTest.java index ce96448..d93ea73 100644 --- a/src/test/java/com/edgareldy/springboottutorial/dto/auth/AuthDtoMaskingTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/dto/auth/AuthDtoMaskingTest.java @@ -36,10 +36,10 @@ void _02_ShouldMaskPassword_WhenLoginRequestPrinted() { @Test void _03_ShouldMaskToken_WhenAuthResponsePrinted() { - String result = new AuthResponse("signed.jwt.token", "Bearer", "refresh.jwt.token").toString(); + String result = new AuthResponse("signed.jwt.token", "Bearer", "refresh.jwt.token", 3600L).toString(); assertThat(result) - .contains("Bearer") + .contains("Bearer", "3600") .doesNotContain("signed.jwt.token", "refresh.jwt.token"); } } From 4c3a922c9a17b7b17ec141709f19b9abf06bdad0 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 19:02:45 +0400 Subject: [PATCH 84/85] fix(test): update _16_ to read access_token JSON field in SecurityAuthorizationTest --- .../controller/SecurityAuthorizationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java b/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java index 1a72315..f035bd2 100644 --- a/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java @@ -213,7 +213,7 @@ void _16_ShouldReturn401_WhenTokenIsUsedAfterLogout() throws Exception { .andExpect(status().isOk()) .andReturn(); String token = objectMapper.readTree(loginResult.getResponse().getContentAsString()) - .path("data").path("token").asText(); + .path("data").path("access_token").asText(); mockMvc.perform(get("/api/v1/customers").header("Authorization", "Bearer " + token)) .andExpect(status().isOk()); From 0f37cb26602a70d5784b9074d4cbf746260f8824 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 9 Nov 2026 19:08:57 +0400 Subject: [PATCH 85/85] fix(test): read access_token field in _20_ of SecurityAuthorizationTest Test _20_ (locked account) was still reading .path("data").path("token") after the AuthResponse field was renamed to accessToken / access_token (JSON). The token extracted was therefore empty, making the first authenticated request return 401 instead of 200, failing the assertion at line 241. Same fix already applied to _16_ on feature/token-management. --- .../controller/SecurityAuthorizationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java b/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java index 0cb5e25..4b30059 100644 --- a/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java @@ -234,7 +234,7 @@ void _20_ShouldReturn401_WhenValidTokenBelongsToLockedAccount() throws Exception .andExpect(status().isOk()) .andReturn(); String token = objectMapper.readTree(loginResult.getResponse().getContentAsString()) - .path("data").path("token").asText(); + .path("data").path("access_token").asText(); // Account is now accessible with the token. mockMvc.perform(get("/api/v1/customers").header("Authorization", "Bearer " + token))