Jelajahi Sumber

improved refresh token api

Daniel Bohry 1 Minggu lalu
induk
melakukan
8a4834b64c

+ 1 - 0
build.gradle

@@ -31,6 +31,7 @@ dependencies {
     runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
     implementation 'com.google.guava:guava:33.4.0-jre'
     implementation 'org.apache.commons:commons-lang3:3.19.0'
+    implementation 'commons-collections:commons-collections:3.2.2'
     implementation 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.2'
 
     compileOnly 'org.projectlombok:lombok'

+ 13 - 2
src/main/java/com/danielbohry/authservice/api/AuthController.java

@@ -9,6 +9,8 @@ import lombok.AllArgsConstructor;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.*;
 
+import jakarta.servlet.http.HttpServletRequest;
+
 import static org.springframework.http.HttpStatus.CREATED;
 import static org.springframework.http.HttpStatus.FORBIDDEN;
 
@@ -41,10 +43,11 @@ public class AuthController {
     }
 
     @PostMapping("refresh")
-    public ResponseEntity<Object> refresh() {
+    public ResponseEntity<Object> refresh(HttpServletRequest request) {
         try {
             ApplicationUser user = SecurityUtils.getCurrentUser();
-            AuthenticationResponse response = service.refresh(user);
+            String currentToken = extractTokenFromRequest(request);
+            AuthenticationResponse response = service.refresh(user, currentToken);
             return response != null
                     ? ResponseEntity.ok(response)
                     : ResponseEntity.status(FORBIDDEN).build();
@@ -59,4 +62,12 @@ public class AuthController {
         return ResponseEntity.ok().build();
     }
 
+    private String extractTokenFromRequest(HttpServletRequest request) {
+        String authHeader = request.getHeader("Authorization");
+        if (authHeader != null && authHeader.startsWith("Bearer ")) {
+            return authHeader.substring(7);
+        }
+        return null;
+    }
+
 }

+ 37 - 14
src/main/java/com/danielbohry/authservice/service/auth/AuthService.java

@@ -7,12 +7,11 @@ import com.danielbohry.authservice.domain.ApplicationUser;
 import com.danielbohry.authservice.service.user.UserService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections.CollectionUtils;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.security.authentication.AuthenticationManager;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.core.authority.SimpleGrantedAuthority;
-import org.springframework.security.core.context.SecurityContext;
-import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.security.core.userdetails.User;
 import org.springframework.security.core.userdetails.UserDetails;
 import org.springframework.security.core.userdetails.UserDetailsService;
@@ -20,6 +19,8 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
 import org.springframework.security.crypto.password.PasswordEncoder;
 import org.springframework.stereotype.Service;
 
+import java.util.List;
+
 import static com.danielbohry.authservice.service.auth.UserConverter.convert;
 import static java.time.Instant.now;
 
@@ -69,15 +70,37 @@ public class AuthService implements UserDetailsService {
         return buildResponse(user, authentication);
     }
 
-    public AuthenticationResponse refresh(ApplicationUser applicationUser) {
+    public AuthenticationResponse refresh(ApplicationUser applicationUser, String currentToken) {
         if (!applicationUser.isActive()) {
             return null;
         }
 
         ApplicationUser user = service.findByUsername(applicationUser.getUsername());
+
+        if (currentToken != null && !jwtService.isTokenExpired(currentToken) && !jwtService.isTokenNearExpiration(currentToken)) {
+            @SuppressWarnings("unchecked")
+            List<String> tokenAuthorities = (List<String>) jwtService.extractClaim(currentToken, claims -> claims.get("authorities"));
+
+            List<String> currentUserAuthorities = user.getRoles().stream()
+                    .map(Enum::name)
+                    .toList();
+
+            if (CollectionUtils.isEqualCollection(tokenAuthorities, currentUserAuthorities)){
+                return AuthenticationResponse.builder()
+                        .id(user.getId())
+                        .username(user.getUsername())
+                        .email(user.getEmail())
+                        .token(currentToken)
+                        .expirationDate(jwtService.extractExpiration(currentToken).toInstant())
+                        .roles(tokenAuthorities)
+                        .build();
+            }
+        }
+
         Authentication authentication = jwtService.generateToken(user);
         user.setLastLoginAt(now());
         service.update(user.getId(), user);
+        log.info("Token refreshed for username [{}]", user.getUsername());
         return buildResponse(user, authentication);
     }
 
@@ -134,17 +157,17 @@ public class AuthService implements UserDetailsService {
 
     private String buildContent(String username, String resetUrl) {
         return String.format("""
-            Hello %s,
-
-            You requested a password reset for your account.
-
-            Click here to reset your password: %s
-
-            This link expires in 10 minutes.
-            If you didn't request this, please ignore this email.
-
-            Auth Service Team
-            """, username, resetUrl);
+                Hello %s,
+                
+                You requested a password reset for your account.
+                
+                Click here to reset your password: %s
+                
+                This link expires in 10 minutes.
+                If you didn't request this, please ignore this email.
+                
+                Auth Service Team
+                """, username, resetUrl);
     }
 
 }

+ 10 - 0
src/main/java/com/danielbohry/authservice/service/auth/JwtService.java

@@ -29,6 +29,9 @@ public class JwtService {
     @Value("${jwt.secret}")
     private String secret;
 
+    @Value("${jwt.refresh-threshold-minutes:30}")
+    private long refreshThresholdMinutes;
+
     private static final Map<String, Long> ROLE_EXPIRATION_MINUTES = Map.of(
             "SYSTEM", 1L,
             "ADMIN", 60L,
@@ -101,6 +104,13 @@ public class JwtService {
         return extractExpiration(token).before(new Date());
     }
 
+    public Boolean isTokenNearExpiration(String token) {
+        Date expiration = extractExpiration(token);
+        Date now = new Date();
+        Date thresholdDate = new Date(now.getTime() + (refreshThresholdMinutes * 60 * 1000));
+        return expiration.before(thresholdDate);
+    }
+
     private Authentication generateToken(Map<String, Object> claims, UserDetails userDetails) {
         Date expirationDate = new Date(currentTimeMillis() + 1000 * 60 * minutesByRole(claims));
         String token = Jwts.builder()

+ 192 - 0
src/test/java/com/danielbohry/authservice/service/auth/AuthServiceTest.java

@@ -1,5 +1,6 @@
 package com.danielbohry.authservice.service.auth;
 
+import com.danielbohry.authservice.JwtTestUtils;
 import com.danielbohry.authservice.api.dto.AuthenticationRequest;
 import com.danielbohry.authservice.api.dto.AuthenticationResponse;
 import com.danielbohry.authservice.client.MailClient;
@@ -18,11 +19,14 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
 import org.springframework.security.crypto.password.PasswordEncoder;
 
 import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
 import java.util.List;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.*;
 
 class AuthServiceTest {
@@ -262,4 +266,192 @@ class AuthServiceTest {
             "password123".equals(auth.getCredentials())
         ));
     }
+
+    @Test
+    void shouldReturnExistingTokenWhenNotNearExpiration() {
+        // given
+        String currentToken = "valid-jwt-token";
+        Date tokenExpiration = new Date(System.currentTimeMillis() + (2 * 60 * 60 * 1000));
+        List<String> authorities = List.of("USER");
+
+        when(userService.findByUsername("testuser")).thenReturn(testUser);
+        when(jwtService.isTokenExpired(currentToken)).thenReturn(false);
+        when(jwtService.isTokenNearExpiration(currentToken)).thenReturn(false);
+        when(jwtService.extractExpiration(currentToken)).thenReturn(tokenExpiration);
+        when(jwtService.extractClaim(eq(currentToken), any())).thenReturn(authorities);
+        when(jwtService.generateToken(testUser)).thenReturn(testAuthentication);
+
+        // when
+        AuthenticationResponse result = authService.refresh(testUser, currentToken);
+
+        // then
+        assertNotNull(result);
+        assertEquals(testUser.getId(), result.getId());
+        assertEquals(testUser.getUsername(), result.getUsername());
+        assertEquals(testUser.getEmail(), result.getEmail());
+        assertEquals(currentToken, result.getToken());
+        assertEquals(tokenExpiration.toInstant(), result.getExpirationDate());
+        assertEquals(List.of("USER"), result.getRoles());
+
+        verify(userService).findByUsername("testuser");
+        verify(jwtService).isTokenExpired(currentToken);
+        verify(jwtService).isTokenNearExpiration(currentToken);
+        verify(jwtService).extractExpiration(currentToken);
+        verify(userService, never()).update(anyString(), any(ApplicationUser.class));
+        verify(jwtService, never()).generateToken(any(ApplicationUser.class));
+    }
+
+    @Test
+    void shouldGenerateNewTokenWhenNearExpiration() {
+        // given
+        String currentToken = JwtTestUtils.createTokenWithExpiration("testuser", 15, ChronoUnit.MINUTES);
+
+        when(userService.findByUsername("testuser")).thenReturn(testUser);
+        when(jwtService.isTokenExpired(currentToken)).thenReturn(false);
+        when(jwtService.isTokenNearExpiration(currentToken)).thenReturn(true);
+        when(jwtService.generateToken(testUser)).thenReturn(testAuthentication);
+
+        // when
+        AuthenticationResponse result = authService.refresh(testUser, currentToken);
+
+        // then
+        assertNotNull(result);
+        assertEquals(testUser.getId(), result.getId());
+        assertEquals(testUser.getUsername(), result.getUsername());
+        assertEquals(testUser.getEmail(), result.getEmail());
+        assertEquals("jwt-token-123", result.getToken()); // Should return new token
+        assertEquals(testAuthentication.expirationDate(), result.getExpirationDate());
+        assertEquals(List.of("ROLE_USER"), result.getRoles());
+
+        verify(userService).findByUsername("testuser");
+        verify(jwtService).isTokenExpired(currentToken);
+        verify(jwtService).isTokenNearExpiration(currentToken);
+        verify(jwtService).generateToken(testUser);
+        verify(userService).update(testUser.getId(), testUser); // Should update lastLoginAt
+    }
+
+    @Test
+    void shouldGenerateNewTokenWhenTokenIsExpired() {
+        // given
+        String expiredToken = JwtTestUtils.createExpiredToken("testuser");
+
+        when(userService.findByUsername("testuser")).thenReturn(testUser);
+        when(jwtService.isTokenExpired(expiredToken)).thenReturn(true);
+        when(jwtService.generateToken(testUser)).thenReturn(testAuthentication);
+
+        // when
+        AuthenticationResponse result = authService.refresh(testUser, expiredToken);
+
+        // then
+        assertNotNull(result);
+        assertEquals(testUser.getId(), result.getId());
+        assertEquals(testUser.getUsername(), result.getUsername());
+        assertEquals(testUser.getEmail(), result.getEmail());
+        assertEquals("jwt-token-123", result.getToken()); // Should return new token
+        assertEquals(testAuthentication.expirationDate(), result.getExpirationDate());
+        assertEquals(List.of("ROLE_USER"), result.getRoles());
+
+        verify(userService).findByUsername("testuser");
+        verify(jwtService).isTokenExpired(expiredToken);
+        verify(jwtService).generateToken(testUser);
+        verify(userService).update(testUser.getId(), testUser); // Should update lastLoginAt
+        verify(jwtService, never()).isTokenNearExpiration(expiredToken); // Should not check near expiration if already expired
+    }
+
+    @Test
+    void shouldGenerateNewTokenWhenCurrentTokenIsNull() {
+        // given
+        when(userService.findByUsername("testuser")).thenReturn(testUser);
+        when(jwtService.generateToken(testUser)).thenReturn(testAuthentication);
+
+        // when
+        AuthenticationResponse result = authService.refresh(testUser, null);
+
+        // then
+        assertNotNull(result);
+        assertEquals(testUser.getId(), result.getId());
+        assertEquals(testUser.getUsername(), result.getUsername());
+        assertEquals(testUser.getEmail(), result.getEmail());
+        assertEquals("jwt-token-123", result.getToken()); // Should return new token
+        assertEquals(testAuthentication.expirationDate(), result.getExpirationDate());
+        assertEquals(List.of("ROLE_USER"), result.getRoles());
+
+        verify(userService).findByUsername("testuser");
+        verify(jwtService).generateToken(testUser);
+        verify(userService).update(testUser.getId(), testUser); // Should update lastLoginAt
+        verify(jwtService, never()).isTokenExpired(any());
+        verify(jwtService, never()).isTokenNearExpiration(any());
+    }
+
+    @Test
+    void shouldGenerateNewTokenWhenRolesHaveChanged() {
+        // given
+        String currentToken = "valid-jwt-token";
+        Date tokenExpiration = new Date(System.currentTimeMillis() + (2 * 60 * 60 * 1000)); // 2 hours from now
+        List<String> tokenAuthorities = List.of("ROLE_USER"); // Token has only USER role
+
+        // User now has ADMIN role in addition to USER
+        ApplicationUser userWithNewRoles = ApplicationUser.builder()
+                .id("user-id-123")
+                .username("testuser")
+                .password("encodedPassword")
+                .email("[email protected]")
+                .roles(List.of(Role.ADMIN, Role.USER))
+                .active(true)
+                .build();
+
+        when(userService.findByUsername("testuser")).thenReturn(userWithNewRoles);
+        when(jwtService.isTokenExpired(currentToken)).thenReturn(false);
+        when(jwtService.isTokenNearExpiration(currentToken)).thenReturn(false);
+        when(jwtService.extractExpiration(currentToken)).thenReturn(tokenExpiration);
+        when(jwtService.extractClaim(eq(currentToken), any())).thenReturn(tokenAuthorities);
+        when(jwtService.generateToken(userWithNewRoles)).thenReturn(testAuthentication);
+
+        // when
+        AuthenticationResponse result = authService.refresh(testUser, currentToken);
+
+        // then
+        assertNotNull(result);
+        assertEquals(userWithNewRoles.getId(), result.getId());
+        assertEquals(userWithNewRoles.getUsername(), result.getUsername());
+        assertEquals(userWithNewRoles.getEmail(), result.getEmail());
+        assertEquals("jwt-token-123", result.getToken()); // Should return new token
+        assertEquals(testAuthentication.expirationDate(), result.getExpirationDate());
+        assertEquals(List.of("ROLE_USER"), result.getRoles()); // From new token
+
+        verify(userService).findByUsername("testuser");
+        verify(jwtService).isTokenExpired(currentToken);
+        verify(jwtService).isTokenNearExpiration(currentToken);
+        verify(jwtService).extractClaim(eq(currentToken), any());
+        verify(jwtService).generateToken(userWithNewRoles); // Should generate new token
+        verify(userService).update(userWithNewRoles.getId(), userWithNewRoles); // Should update lastLoginAt
+    }
+
+    @Test
+    void shouldReturnNullWhenUserIsInactive() {
+        // given
+        ApplicationUser inactiveUser = ApplicationUser.builder()
+                .id("inactive-user-id")
+                .username("inactiveuser")
+                .password("encodedPassword")
+                .email("[email protected]")
+                .roles(List.of(Role.USER))
+                .active(false)
+                .build();
+
+        String currentToken = JwtTestUtils.createValidToken("inactiveuser");
+
+        // when
+        AuthenticationResponse result = authService.refresh(inactiveUser, currentToken);
+
+        // then
+        assertNull(result);
+
+        verify(userService, never()).findByUsername(anyString());
+        verify(jwtService, never()).isTokenExpired(any());
+        verify(jwtService, never()).isTokenNearExpiration(any());
+        verify(jwtService, never()).generateToken(any());
+        verify(userService, never()).update(anyString(), any());
+    }
+
 }