diff --git a/src/main/java/com/edgareldy/springboottutorial/config/SecurityConfig.java b/src/main/java/com/edgareldy/springboottutorial/config/SecurityConfig.java index c683d55..0ff6bea 100644 --- a/src/main/java/com/edgareldy/springboottutorial/config/SecurityConfig.java +++ b/src/main/java/com/edgareldy/springboottutorial/config/SecurityConfig.java @@ -73,7 +73,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .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() diff --git a/src/main/java/com/edgareldy/springboottutorial/controller/AuthController.java b/src/main/java/com/edgareldy/springboottutorial/controller/AuthController.java index 6dedb5b..888ba6c 100644 --- a/src/main/java/com/edgareldy/springboottutorial/controller/AuthController.java +++ b/src/main/java/com/edgareldy/springboottutorial/controller/AuthController.java @@ -7,9 +7,11 @@ 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.security.JwtService; import com.edgareldy.springboottutorial.service.AuthService; import com.edgareldy.springboottutorial.service.UserService; import jakarta.validation.Valid; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -43,6 +45,16 @@ public class AuthController { private final AuthService authService; 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) { @@ -68,6 +80,16 @@ public ResponseEntity> me(@AuthenticationPrincipal Use return ResponseEntity.ok(ApiResponse.success(authService.me(userDetails.getUsername()), "Profile retrieved successfully")); } + @GetMapping("/token-info") + 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")); + } + @GetMapping("/activate") public ResponseEntity> activate(@RequestParam String token) { return ResponseEntity.ok(ApiResponse.success(userService.activate(token), "Account activated successfully")); @@ -80,8 +102,10 @@ public ResponseEntity> forgotPassword(@Valid @RequestBody Forg } @PostMapping("/reset-password") - public ResponseEntity> resetPassword(@Valid @RequestBody ResetPasswordRequest request) { - userService.resetPassword(request.token(), request.newPassword()); + public ResponseEntity> resetPassword( + @RequestParam String token, + @Valid @RequestBody ResetPasswordRequest request) { + userService.resetPassword(token, request.newPassword()); return ResponseEntity.ok(ApiResponse.success(null, "Password reset successfully")); } } 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); 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..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,11 +3,14 @@ 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} 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 @@ -15,12 +18,17 @@ * Project : spring-boot-tutorial */ public record AuthResponse( - String token, + @JsonProperty("access_token") + String accessToken, @JsonProperty("token_type") String tokenType, - String username + @JsonProperty("refresh_token") + String refreshToken, + + @JsonProperty("expires_in") + long expiresIn ) { // Overrides the compiler-generated toString(): LoggingAspect logs every @@ -29,6 +37,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[accessToken=***, tokenType=%s, refreshToken=***, expiresIn=%d]" + .formatted(tokenType, expiresIn); } } diff --git a/src/main/java/com/edgareldy/springboottutorial/dto/auth/ResetPasswordRequest.java b/src/main/java/com/edgareldy/springboottutorial/dto/auth/ResetPasswordRequest.java index fa395b3..add1fd6 100644 --- a/src/main/java/com/edgareldy/springboottutorial/dto/auth/ResetPasswordRequest.java +++ b/src/main/java/com/edgareldy/springboottutorial/dto/auth/ResetPasswordRequest.java @@ -1,25 +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; -import jakarta.validation.constraints.Size; /** - * Request body for the POST /auth/reset-password endpoint. Carries the - * one-time reset token (delivered by email) and the new plain-text password - * to set after the token is validated and consumed. + * 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 10/30/26 * Author : edgar.muhamyangabo * Date : 10/30/26 * Project : spring-boot-tutorial */ +@PasswordsMatch(passwordField = "newPassword", confirmPasswordField = "confirmPassword", + message = "Passwords must match") public record ResetPasswordRequest( - @NotBlank(message = "Token must not be blank") - @JsonProperty("token") String token, - @NotBlank(message = "New password must not be blank") - @Size(min = 8, message = "New password must be at least 8 characters") - @JsonProperty("new_password") String newPassword + @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=***]"; + } } 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; 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; +} 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; } 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; 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; +} diff --git a/src/main/java/com/edgareldy/springboottutorial/exception/GlobalExceptionHandler.java b/src/main/java/com/edgareldy/springboottutorial/exception/GlobalExceptionHandler.java index 3058eb8..b1e3e1b 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; @@ -51,7 +52,7 @@ public ResponseEntity> handleResourceNotFound( @ExceptionHandler(BusinessRuleException.class) public ResponseEntity> handleBusinessRule( BusinessRuleException ex, HttpServletRequest request) { - return buildResponse(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage(), request, null); + return buildResponse(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage(), request); } @ExceptionHandler(InvalidTokenException.class) @@ -69,13 +70,19 @@ public ResponseEntity> handleBadCredentials( @ExceptionHandler(LockedException.class) public ResponseEntity> handleLocked( LockedException ex, HttpServletRequest request) { - return buildResponse(HttpStatus.UNAUTHORIZED, "Account is locked", request); + return buildResponse(HttpStatus.LOCKED, "Account is locked", request); } @ExceptionHandler(DisabledException.class) public ResponseEntity> handleDisabled( DisabledException ex, HttpServletRequest request) { - return buildResponse(HttpStatus.UNAUTHORIZED, "Account is not activated", request); + return buildResponse(HttpStatus.FORBIDDEN, "Account is not activated", request); + } + + @ExceptionHandler(ExpiredJwtException.class) + public ResponseEntity> handleExpiredJwt( + ExpiredJwtException ex, HttpServletRequest request) { + return buildResponse(HttpStatus.UNAUTHORIZED, "JWT token has expired", request); } @ExceptionHandler(AccessDeniedException.class) 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); + } +} 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); } 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); } 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); +} 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"); + } } 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) { } diff --git a/src/main/java/com/edgareldy/springboottutorial/security/JwtService.java b/src/main/java/com/edgareldy/springboottutorial/security/JwtService.java index f275535..9633360 100644 --- a/src/main/java/com/edgareldy/springboottutorial/security/JwtService.java +++ b/src/main/java/com/edgareldy/springboottutorial/security/JwtService.java @@ -4,12 +4,14 @@ import com.edgareldy.springboottutorial.entity.Role; import com.edgareldy.springboottutorial.entity.user.AppUser; import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import java.time.Instant; import java.util.Date; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.UUID; import javax.crypto.SecretKey; import org.springframework.stereotype.Component; @@ -31,12 +33,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(AppUser user) { @@ -74,6 +82,35 @@ public Instant extractExpiration(String token) { return parseClaims(token).getExpiration().toInstant(); } + 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 long getExpirationMs() { + return expirationMs; + } + + public Map extractAllClaims(String token) { + return parseClaims(token); + } + private Claims parseClaims(String token) { return Jwts.parser() .verifyWith(signingKey) 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/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); 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); +} diff --git a/src/main/java/com/edgareldy/springboottutorial/service/UserService.java b/src/main/java/com/edgareldy/springboottutorial/service/UserService.java index f64a360..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); @@ -31,6 +33,7 @@ public interface UserService { UserResponse updateProfile(Long id, UpdateProfileRequest request); + UserResponse activate(String token); void forgotPassword(String email); 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..69e35b3 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; @@ -33,6 +34,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() @@ -45,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) { @@ -55,7 +65,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); 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 f6978fa..85d9694 100644 --- a/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImpl.java @@ -12,10 +12,12 @@ import com.edgareldy.springboottutorial.repository.AppUserRepository; import com.edgareldy.springboottutorial.repository.RoleRepository; 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 java.util.Set; import lombok.RequiredArgsConstructor; @@ -50,6 +52,7 @@ public class AuthServiceImpl implements AuthService { private final ActivationTokenService activationTokenService; private final EmailService emailService; private final BlacklistedTokenService blacklistedTokenService; + private final RefreshTokenService refreshTokenService; @Override @Transactional @@ -72,7 +75,7 @@ public UserResponse register(RegisterRequest request) { } @Override - @Transactional(readOnly = true) + @Transactional public AuthResponse login(LoginRequest request) { // The client sends their email; Spring Security's AuthenticationManager // delegates to UserDetailsServiceImpl.loadUserByUsername() which looks @@ -82,8 +85,21 @@ public AuthResponse login(LoginRequest request) { .orElseThrow(() -> new BadCredentialsException("Invalid credentials")); authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(appUser.getUsername(), request.password())); - String token = jwtService.generateToken(appUser); - return new AuthResponse(token, BEARER_TOKEN_TYPE, appUser.getUsername()); + String accessToken = jwtService.generateToken(appUser); + RefreshToken refreshToken = refreshTokenService.generate(appUser); + long expiresIn = jwtService.getExpirationMs() / 1000; + return new AuthResponse(accessToken, BEARER_TOKEN_TYPE, refreshToken.getToken(), expiresIn); + } + + @Override + @Transactional + public AuthResponse refresh(String rawRefreshToken) { + RefreshToken refreshToken = refreshTokenService.validate(rawRefreshToken); + AppUser appUser = refreshToken.getUser(); + String newAccessToken = jwtService.generateToken(appUser); + RefreshToken newRefreshToken = refreshTokenService.generate(appUser); + long expiresIn = jwtService.getExpirationMs() / 1000; + return new AuthResponse(newAccessToken, BEARER_TOKEN_TYPE, newRefreshToken.getToken(), expiresIn); } @Override @@ -95,6 +111,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 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..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,12 +38,15 @@ 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() .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; 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); + } +} 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..2deb3f0 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; @@ -68,7 +69,18 @@ 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)); + } + + @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)); } @@ -120,15 +132,28 @@ 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 @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); }); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 366aae7..b054a89 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,3 +17,4 @@ app: jwt: secret: ${JWT_SECRET:dev-only-jwt-secret-please-override-in-production-0123456789} expiration-ms: 3600000 + refresh-expiration-ms: 604800000 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; 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); diff --git a/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java b/src/test/java/com/edgareldy/springboottutorial/controller/AuthControllerTest.java index ffb8b9b..500c585 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.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; @@ -24,8 +25,10 @@ import com.edgareldy.springboottutorial.security.JwtService; 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.List; +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; @@ -78,6 +81,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", false, false, @@ -117,14 +124,15 @@ 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", "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.token").value("signed-token")) - .andExpect(jsonPath("$.data.token_type").value("Bearer")); + .andExpect(jsonPath("$.data.access_token").value("signed-token")) + .andExpect(jsonPath("$.data.token_type").value("Bearer")) + .andExpect(jsonPath("$.data.expires_in").value(3600)); } @Test @@ -190,6 +198,8 @@ void _10_ShouldReturn200_WhenForgotPasswordRequestValid() throws Exception { new ForgotPasswordRequest("ada@example.com")))) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)); + + verify(userService).forgotPassword("ada@example.com"); } @Test @@ -203,22 +213,24 @@ void _11_ShouldReturn400_WhenForgotPasswordEmailInvalid() throws Exception { @Test void _12_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("valid-token", "newpassword")))) + .content(objectMapper.writeValueAsString(new ResetPasswordRequest("Password1@", "Password1@")))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)); + .andExpect(jsonPath("$.message").value("Password reset successfully")); + + verify(userService).resetPassword("reset-token", "Password1@"); } @Test void _13_ShouldReturn400_WhenResetPasswordTokenInvalid() throws Exception { org.mockito.Mockito.doThrow(new InvalidTokenException("Invalid password reset token")) - .when(userService).resetPassword("bad-token", "newpassword"); + .when(userService).resetPassword("bad-token", "Password1@"); mockMvc.perform(post("/api/v1/auth/reset-password") + .param("token", "bad-token") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString( - new ResetPasswordRequest("bad-token", "newpassword")))) + .content(objectMapper.writeValueAsString(new ResetPasswordRequest("Password1@", "Password1@")))) .andExpect(status().isBadRequest()); } @@ -239,4 +251,55 @@ void _15_ShouldReturn401_WhenLogoutWithoutAuthentication() throws Exception { .header("Authorization", "Bearer signed-token")) .andExpect(status().isUnauthorized()); } + + @Test + void _16_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 _17_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)); + } + + @Test + void _16_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 _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", "new-refresh-token", 3600L)); + + mockMvc.perform(post("/api/v1/auth/refresh-token") + .header("Authorization", "Bearer refresh-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.access_token").value("new-access-token")) + .andExpect(jsonPath("$.data.refresh_token").value("new-refresh-token")) + .andExpect(jsonPath("$.message").value("Token refreshed successfully")); + } } diff --git a/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java b/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java index 31e7d2f..4b30059 100644 --- a/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/controller/SecurityAuthorizationTest.java @@ -172,7 +172,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()); @@ -203,6 +203,11 @@ void _18_ShouldReturn401_WhenPasswordIsWrong() throws Exception { .content(objectMapper.writeValueAsString(register))) .andExpect(status().isCreated()); + 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") .contentType(MediaType.APPLICATION_JSON) @@ -229,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)) @@ -267,7 +272,7 @@ void _19_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()); 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")) 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..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,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", "refresh.jwt.token", 3600L).toString(); - assertThat(result).contains("Bearer", "ada").doesNotContain("signed.jwt.token"); + assertThat(result) + .contains("Bearer", "3600") + .doesNotContain("signed.jwt.token", "refresh.jwt.token"); } } diff --git a/src/test/java/com/edgareldy/springboottutorial/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/edgareldy/springboottutorial/exception/GlobalExceptionHandlerTest.java index e1b84ed..dad2a85 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; @@ -103,27 +104,27 @@ void _05_ShouldReturn401_WhenCredentialsAreBad() { } @Test - void _06_ShouldReturn401_WhenAccountIsLocked() { + void _06_ShouldReturn423_WhenAccountIsLocked() { HttpServletRequest request = mockRequest("/api/v1/auth/login"); ResponseEntity> response = handler.handleLocked(new LockedException("Account is locked"), request); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getStatusCode().value()).isEqualTo(423); assertThat(response.getBody().message()).isEqualTo("Account is locked"); - assertThat(response.getBody().data().getStatus()).isEqualTo(401); + assertThat(response.getBody().data().getStatus()).isEqualTo(423); } @Test - void _07_ShouldReturn401_WhenAccountIsDisabled() { + void _07_ShouldReturn403_WhenAccountIsDisabled() { HttpServletRequest request = mockRequest("/api/v1/auth/login"); ResponseEntity> response = handler.handleDisabled(new DisabledException("Account is not activated"), request); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); assertThat(response.getBody().message()).isEqualTo("Account is not activated"); - assertThat(response.getBody().data().getStatus()).isEqualTo(401); + assertThat(response.getBody().data().getStatus()).isEqualTo(403); } @Test @@ -177,6 +178,18 @@ void _11_ShouldReturn500_WhenUnexpectedExceptionThrown() { assertThat(response.getBody().data().getStatus()).isEqualTo(500); } + @Test + void _12_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); diff --git a/src/test/java/com/edgareldy/springboottutorial/repository/PasswordResetTokenRepositoryTest.java b/src/test/java/com/edgareldy/springboottutorial/repository/PasswordResetTokenRepositoryTest.java index 193f2b1..bdb28ad 100644 --- a/src/test/java/com/edgareldy/springboottutorial/repository/PasswordResetTokenRepositoryTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/repository/PasswordResetTokenRepositoryTest.java @@ -46,6 +46,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()); @@ -65,10 +66,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()); diff --git a/src/test/java/com/edgareldy/springboottutorial/security/JwtServiceTest.java b/src/test/java/com/edgareldy/springboottutorial/security/JwtServiceTest.java index c7caf9f..ffde7d7 100644 --- a/src/test/java/com/edgareldy/springboottutorial/security/JwtServiceTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/security/JwtServiceTest.java @@ -25,13 +25,15 @@ 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; private AppUser ada; @BeforeEach void setUp() { - jwtService = new JwtService(new JwtProperties(SECRET, 3600000L)); + jwtService = new JwtService(new JwtProperties(SECRET, EXPIRATION_MS, REFRESH_EXPIRATION_MS)); Permission readPermission = Permission.builder().id(1L).resource("PRODUCT").action("READ").build(); Role userRole = Role.builder().id(1L).roleName("USER") .permissions(Set.of(readPermission)).build(); @@ -94,4 +96,28 @@ void _05_ShouldEmbedRolesAndPermissions_WhenUserHasRoles() { assertThat(jwtService.extractUsername(token)).isEqualTo("admin"); assertThat(jwtService.extractJti(token)).isNotBlank(); } + + @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"); + } } 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..944d803 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,25 @@ void _05_ShouldThrowException_WhenTokenIsExpired() { when(activationTokenRepository.findByToken("raw-token")).thenReturn(Optional.of(activationToken)); 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); } } 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 88cdde6..08d307a 100644 --- a/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/AuthServiceImplTest.java @@ -20,9 +20,11 @@ import com.edgareldy.springboottutorial.repository.AppUserRepository; import com.edgareldy.springboottutorial.repository.RoleRepository; 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.List; import java.util.Optional; @@ -81,6 +83,9 @@ class AuthServiceImplTest { @Mock private BlacklistedTokenService blacklistedTokenService; + @Mock + private RefreshTokenService refreshTokenService; + @InjectMocks private AuthServiceImpl authService; @@ -123,13 +128,18 @@ void _01_ShouldSaveUserDisabledAndSendActivationEmail_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(appUser)).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", "ada")); + assertThat(response).isEqualTo(new AuthResponse("signed-token", "Bearer", "refresh-token", 3600L)); verify(authenticationManager).authenticate(new UsernamePasswordAuthenticationToken("ada", "password123")); + verify(refreshTokenService).generate(appUser); } @Test @@ -173,7 +183,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); @@ -185,6 +195,7 @@ void _08_ShouldBlacklistToken_WhenLogoutCalled() { authService.logout(rawToken); verify(blacklistedTokenService).blacklist(appUser, rawToken, jti, expiresAt); + verify(refreshTokenService).deleteByUser(appUser); } @Test 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); 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); + } +} 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..c710c9a 100644 --- a/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java +++ b/src/test/java/com/edgareldy/springboottutorial/service/impl/UserServiceImplTest.java @@ -17,12 +17,14 @@ 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; 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; @@ -83,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()))); } @@ -134,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)); @@ -146,6 +164,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)); @@ -180,8 +226,24 @@ 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() { + appUser.setEnabled(true); when(appUserRepository.findByEmailIgnoreCase("ada@example.com")) .thenReturn(Optional.of(appUser)); when(passwordResetTokenService.generate(appUser)).thenReturn("reset-token"); @@ -202,6 +264,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);