diff --git a/src/main/java/com/somemore/auth/jwt/exception/JwtErrorType.java b/src/main/java/com/somemore/auth/jwt/exception/JwtErrorType.java new file mode 100644 index 000000000..92a624305 --- /dev/null +++ b/src/main/java/com/somemore/auth/jwt/exception/JwtErrorType.java @@ -0,0 +1,15 @@ +package com.somemore.auth.jwt.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum JwtErrorType { + MISSING_TOKEN("JWT 토큰이 없습니다."), + INVALID_TOKEN("JWT 서명이 유효하지 않습니다."), + EXPIRED_TOKEN("JWT 토큰이 만료되었습니다."), + UNKNOWN_ERROR("알 수 없는 JWT 처리 오류가 발생했습니다."); + + private final String message; +} diff --git a/src/main/java/com/somemore/auth/jwt/exception/JwtException.java b/src/main/java/com/somemore/auth/jwt/exception/JwtException.java new file mode 100644 index 000000000..406935484 --- /dev/null +++ b/src/main/java/com/somemore/auth/jwt/exception/JwtException.java @@ -0,0 +1,14 @@ +package com.somemore.auth.jwt.exception; + +import lombok.Getter; + +@Getter +public class JwtException extends RuntimeException { + private final JwtErrorType errorType; + + public JwtException(JwtErrorType errorType) { + super(errorType.getMessage()); + this.errorType = errorType; + } + +} diff --git a/src/main/java/com/somemore/auth/jwt/filter/JwtAuthFilter.java b/src/main/java/com/somemore/auth/jwt/filter/JwtAuthFilter.java new file mode 100644 index 000000000..3f9a2c677 --- /dev/null +++ b/src/main/java/com/somemore/auth/jwt/filter/JwtAuthFilter.java @@ -0,0 +1,67 @@ +package com.somemore.auth.jwt.filter; + +import com.somemore.auth.UserRole; +import com.somemore.auth.jwt.domain.EncodedToken; +import com.somemore.auth.jwt.exception.JwtErrorType; +import com.somemore.auth.jwt.exception.JwtException; +import com.somemore.auth.jwt.usecase.JwtUseCase; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@RequiredArgsConstructor +@Slf4j +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUseCase jwtUseCase; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return true; // 개발 중 모든 요청 허용 +// return httpServletRequest.getRequestURI().contains("token"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + EncodedToken accessToken = getAccessToken(request); + jwtUseCase.processAccessToken(accessToken, response); + + Claims claims = jwtUseCase.getClaims(accessToken); + Authentication auth = createAuthenticationToken(claims, accessToken); + + SecurityContextHolder.getContext().setAuthentication(auth); + filterChain.doFilter(request, response); + } + + private EncodedToken getAccessToken(HttpServletRequest request) { + String accessToken = request.getHeader("Authorization"); + if (accessToken == null || accessToken.isEmpty()) { + throw new JwtException(JwtErrorType.MISSING_TOKEN); + } + return new EncodedToken(accessToken); + } + + private JwtAuthenticationToken createAuthenticationToken(Claims claims, EncodedToken accessToken) { + String userId = claims.get("id", String.class); + UserRole role = claims.get("role", UserRole.class); + + return new JwtAuthenticationToken( + userId, + accessToken, + List.of(new SimpleGrantedAuthority(role.name())) + ); + } +} diff --git a/src/main/java/com/somemore/auth/jwt/filter/JwtAuthenticationToken.java b/src/main/java/com/somemore/auth/jwt/filter/JwtAuthenticationToken.java new file mode 100644 index 000000000..d25a13aee --- /dev/null +++ b/src/main/java/com/somemore/auth/jwt/filter/JwtAuthenticationToken.java @@ -0,0 +1,31 @@ +package com.somemore.auth.jwt.filter; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.io.Serializable; +import java.util.Collection; + +public class JwtAuthenticationToken extends AbstractAuthenticationToken { + private final Serializable principal; + private final transient Object credentials; + + public JwtAuthenticationToken(Serializable principal, + Object credentials, + Collection authorities) { + super(authorities); + this.principal = principal; + this.credentials = credentials; + setAuthenticated(true); + } + + @Override + public Object getCredentials() { + return credentials; + } + + @Override + public Object getPrincipal() { + return principal; + } +} diff --git a/src/main/java/com/somemore/auth/jwt/filter/JwtExceptionFilter.java b/src/main/java/com/somemore/auth/jwt/filter/JwtExceptionFilter.java new file mode 100644 index 000000000..80ac8b5ee --- /dev/null +++ b/src/main/java/com/somemore/auth/jwt/filter/JwtExceptionFilter.java @@ -0,0 +1,50 @@ +package com.somemore.auth.jwt.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.somemore.auth.jwt.exception.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +@Slf4j +@Component +public class JwtExceptionFilter extends OncePerRequestFilter { + + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (JwtException e) { + ProblemDetail problemDetail = buildUnauthorizedProblemDetail(e); + configureUnauthorizedResponse(response); + + objectMapper.writeValue(response.getWriter(), problemDetail); + } + } + + private void configureUnauthorizedResponse(HttpServletResponse response) { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + } + + private ProblemDetail buildUnauthorizedProblemDetail(JwtException e) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, e.getMessage()); + problemDetail.setTitle("Authentication Error"); + problemDetail.setProperty("timestamp", System.currentTimeMillis()); + return problemDetail; + } +} diff --git a/src/main/java/com/somemore/auth/jwt/generator/HmacJwtGenerator.java b/src/main/java/com/somemore/auth/jwt/generator/HmacJwtGenerator.java index 304d103a3..1d25e5bca 100644 --- a/src/main/java/com/somemore/auth/jwt/generator/HmacJwtGenerator.java +++ b/src/main/java/com/somemore/auth/jwt/generator/HmacJwtGenerator.java @@ -11,6 +11,7 @@ import javax.crypto.SecretKey; import java.time.Instant; import java.util.Date; +import java.util.UUID; @Component @RequiredArgsConstructor @@ -23,9 +24,11 @@ public EncodedToken generateToken(String userId, String role, TokenType tokenTyp Claims claims = buildClaims(userId, role); Instant now = Instant.now(); Instant expiration = now.plusMillis(tokenType.getPeriod()); + String uniqueId = UUID.randomUUID().toString(); // JTI return new EncodedToken(Jwts.builder() .claims(claims) + .id(uniqueId) .issuedAt(Date.from(now)) .expiration(Date.from(expiration)) .signWith(secretKey, ALGORITHM) diff --git a/src/main/java/com/somemore/auth/jwt/parser/DefaultJwtParser.java b/src/main/java/com/somemore/auth/jwt/parser/DefaultJwtParser.java index e493db1a3..c7c453da6 100644 --- a/src/main/java/com/somemore/auth/jwt/parser/DefaultJwtParser.java +++ b/src/main/java/com/somemore/auth/jwt/parser/DefaultJwtParser.java @@ -1,8 +1,12 @@ package com.somemore.auth.jwt.parser; import com.somemore.auth.jwt.domain.EncodedToken; +import com.somemore.auth.jwt.exception.JwtErrorType; +import com.somemore.auth.jwt.exception.JwtException; import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.SignatureException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -14,11 +18,21 @@ public class DefaultJwtParser implements JwtParser { private final SecretKey secretKey; + @Override public Claims parseToken(EncodedToken token) { - return Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token.value()) - .getPayload(); + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token.value()) + .getPayload(); + + } catch (SignatureException e) { + throw new JwtException(JwtErrorType.INVALID_TOKEN); + } catch (ExpiredJwtException e) { + throw new JwtException(JwtErrorType.EXPIRED_TOKEN); + } catch (Exception e) { + throw new JwtException(JwtErrorType.UNKNOWN_ERROR); + } } } diff --git a/src/main/java/com/somemore/auth/jwt/refresh/manager/RedisRefreshTokenManager.java b/src/main/java/com/somemore/auth/jwt/refresh/manager/RedisRefreshTokenManager.java index e53292c60..0a2d57e64 100644 --- a/src/main/java/com/somemore/auth/jwt/refresh/manager/RedisRefreshTokenManager.java +++ b/src/main/java/com/somemore/auth/jwt/refresh/manager/RedisRefreshTokenManager.java @@ -1,9 +1,10 @@ package com.somemore.auth.jwt.refresh.manager; import com.somemore.auth.jwt.domain.EncodedToken; +import com.somemore.auth.jwt.exception.JwtErrorType; +import com.somemore.auth.jwt.exception.JwtException; import com.somemore.auth.jwt.refresh.domain.RefreshToken; import com.somemore.auth.jwt.refresh.repository.RefreshTokenRepository; -import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -16,7 +17,7 @@ public class RedisRefreshTokenManager implements RefreshTokenManager { @Override public RefreshToken findRefreshToken(EncodedToken accessToken) { return refreshTokenRepository.findByAccessToken(accessToken.value()) - .orElseThrow(EntityNotFoundException::new); + .orElseThrow(() -> new JwtException(JwtErrorType.EXPIRED_TOKEN)); } @Override @@ -24,11 +25,10 @@ public void save(RefreshToken refreshToken) { refreshTokenRepository.save(refreshToken); } - // TODO 로그아웃에 사용 @Override public void removeRefreshToken(EncodedToken accessToken) { RefreshToken refreshToken = refreshTokenRepository.findByAccessToken(accessToken.value()) - .orElseThrow(EntityNotFoundException::new); + .orElseThrow(() -> new JwtException(JwtErrorType.EXPIRED_TOKEN)); refreshTokenRepository.delete(refreshToken); } diff --git a/src/main/java/com/somemore/auth/jwt/refresh/refresher/DefaultJwtRefresher.java b/src/main/java/com/somemore/auth/jwt/refresh/refresher/DefaultJwtRefresher.java index 1c9f23473..262479eac 100644 --- a/src/main/java/com/somemore/auth/jwt/refresh/refresher/DefaultJwtRefresher.java +++ b/src/main/java/com/somemore/auth/jwt/refresh/refresher/DefaultJwtRefresher.java @@ -25,23 +25,16 @@ public class DefaultJwtRefresher implements JwtRefresher { @Override public EncodedToken refreshAccessToken(EncodedToken accessToken) { RefreshToken refreshToken = refreshTokenManager.findRefreshToken(accessToken); - validateToken(refreshToken); + EncodedToken refreshTokenValue = new EncodedToken(refreshToken.getRefreshToken()); + jwtValidator.validateToken(refreshTokenValue); - Claims claims = jwtParser.parseToken(accessToken); + Claims claims = jwtParser.parseToken(refreshTokenValue); refreshToken.updateAccessToken(generateAccessToken(claims)); refreshTokenManager.save(refreshToken); return new EncodedToken(refreshToken.getAccessToken()); } - private void validateToken(RefreshToken refreshToken) { - if (jwtValidator.validateToken(new EncodedToken(refreshToken.getAccessToken()))) { - // TODO Security Context (JwtFilter) 구현 시 예외 처리 구체화 - log.error("리프레시 토큰이 만료되었습니다. 로그인을 다시 해야합니다"); - throw new RuntimeException(); - } - } - private EncodedToken generateAccessToken(Claims claims) { return jwtGenerator.generateToken( claims.get("id", String.class), diff --git a/src/main/java/com/somemore/auth/jwt/service/JwtService.java b/src/main/java/com/somemore/auth/jwt/service/JwtService.java index 9e9ce8ca6..dd84f0b20 100644 --- a/src/main/java/com/somemore/auth/jwt/service/JwtService.java +++ b/src/main/java/com/somemore/auth/jwt/service/JwtService.java @@ -1,12 +1,17 @@ package com.somemore.auth.jwt.service; +import com.somemore.auth.cookie.SetCookieUseCase; import com.somemore.auth.jwt.domain.EncodedToken; import com.somemore.auth.jwt.domain.TokenType; +import com.somemore.auth.jwt.exception.JwtErrorType; +import com.somemore.auth.jwt.exception.JwtException; import com.somemore.auth.jwt.generator.JwtGenerator; import com.somemore.auth.jwt.parser.JwtParser; import com.somemore.auth.jwt.refresh.refresher.JwtRefresher; import com.somemore.auth.jwt.usecase.JwtUseCase; import com.somemore.auth.jwt.validator.JwtValidator; +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -19,6 +24,7 @@ public class JwtService implements JwtUseCase { private final JwtParser jwtParser; private final JwtValidator jwtValidator; private final JwtRefresher jwtRefresher; + private final SetCookieUseCase setCookieUseCase; @Override public EncodedToken generateToken(String userId, String role, TokenType tokenType) { @@ -26,16 +32,25 @@ public EncodedToken generateToken(String userId, String role, TokenType tokenTyp } @Override - public void verifyToken(EncodedToken token) { - if (jwtValidator.validateToken(token)) { - return; + public void processAccessToken(EncodedToken accessToken, HttpServletResponse response) { + try { + jwtValidator.validateToken(accessToken); + } catch (JwtException e) { + handleJwtExpiredException(e, accessToken, response); } - EncodedToken accessToken = jwtRefresher.refreshAccessToken(token); - // TODO Security Context (JwtFilter) 구현 시 setCookie(accessToken) 구체화 } @Override - public String getClaimByKey(EncodedToken token, String key) { - return jwtParser.parseToken(token).get(key, String.class); + public Claims getClaims(EncodedToken token) { + return jwtParser.parseToken(token); + } + + private void handleJwtExpiredException(JwtException e, EncodedToken accessToken, HttpServletResponse response) { + if (e.getErrorType() == JwtErrorType.EXPIRED_TOKEN) { + EncodedToken refreshedToken = jwtRefresher.refreshAccessToken(accessToken); + setCookieUseCase.setToken(response, refreshedToken.value(), TokenType.ACCESS); + return; + } + throw e; } } diff --git a/src/main/java/com/somemore/auth/jwt/usecase/JwtUseCase.java b/src/main/java/com/somemore/auth/jwt/usecase/JwtUseCase.java index 21c9a3a99..8c43d4e99 100644 --- a/src/main/java/com/somemore/auth/jwt/usecase/JwtUseCase.java +++ b/src/main/java/com/somemore/auth/jwt/usecase/JwtUseCase.java @@ -2,11 +2,14 @@ import com.somemore.auth.jwt.domain.EncodedToken; import com.somemore.auth.jwt.domain.TokenType; +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.HttpServletResponse; public interface JwtUseCase { EncodedToken generateToken(String userId, String role, TokenType tokenType); - void verifyToken(EncodedToken token); + void processAccessToken(EncodedToken token, HttpServletResponse response); + + Claims getClaims(EncodedToken token); - String getClaimByKey(EncodedToken token, String key); } diff --git a/src/main/java/com/somemore/auth/jwt/validator/DefaultJwtValidator.java b/src/main/java/com/somemore/auth/jwt/validator/DefaultJwtValidator.java index 97903f35e..1b313dbfa 100644 --- a/src/main/java/com/somemore/auth/jwt/validator/DefaultJwtValidator.java +++ b/src/main/java/com/somemore/auth/jwt/validator/DefaultJwtValidator.java @@ -1,7 +1,10 @@ package com.somemore.auth.jwt.validator; import com.somemore.auth.jwt.domain.EncodedToken; +import com.somemore.auth.jwt.exception.JwtErrorType; +import com.somemore.auth.jwt.exception.JwtException; import com.somemore.auth.jwt.parser.DefaultJwtParser; +import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -13,10 +16,15 @@ public class DefaultJwtValidator implements JwtValidator { private final DefaultJwtParser defaultJwtParser; - public boolean validateToken(EncodedToken token) { - return defaultJwtParser.parseToken(token) - .getExpiration() - .toInstant() - .isAfter(Instant.now()); + @Override + public void validateToken(EncodedToken token) { + Claims claims = defaultJwtParser.parseToken(token); + validateExpiration(claims); + } + + private void validateExpiration(Claims claims) { + if (claims.getExpiration() == null || claims.getExpiration().toInstant().isBefore(Instant.now())) { + throw new JwtException(JwtErrorType.EXPIRED_TOKEN); + } } } diff --git a/src/main/java/com/somemore/auth/jwt/validator/JwtValidator.java b/src/main/java/com/somemore/auth/jwt/validator/JwtValidator.java index 6f25bbf8e..9f0d6b08b 100644 --- a/src/main/java/com/somemore/auth/jwt/validator/JwtValidator.java +++ b/src/main/java/com/somemore/auth/jwt/validator/JwtValidator.java @@ -3,5 +3,5 @@ import com.somemore.auth.jwt.domain.EncodedToken; public interface JwtValidator { - boolean validateToken(EncodedToken token); + void validateToken(EncodedToken token); } diff --git a/src/main/java/com/somemore/global/configure/SecurityConfig.java b/src/main/java/com/somemore/global/configure/SecurityConfig.java index bc7edb98c..5951294ed 100644 --- a/src/main/java/com/somemore/global/configure/SecurityConfig.java +++ b/src/main/java/com/somemore/global/configure/SecurityConfig.java @@ -1,5 +1,7 @@ package com.somemore.global.configure; +import com.somemore.auth.jwt.filter.JwtAuthFilter; +import com.somemore.auth.jwt.filter.JwtExceptionFilter; import com.somemore.auth.oauth.handler.failure.CustomOAuthFailureHandler; import com.somemore.auth.oauth.handler.success.CustomOAuthSuccessHandler; import com.somemore.auth.oauth.service.CustomOAuth2UserService; @@ -13,6 +15,7 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @RequiredArgsConstructor @Configuration @@ -24,10 +27,12 @@ public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; private final CustomOAuthSuccessHandler customOAuthSuccessHandler; private final CustomOAuthFailureHandler customOAuthFailureHandler; + private final JwtAuthFilter jwtAuthFilter; + private final JwtExceptionFilter jwtExceptionFilter; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { - return httpSecurity + httpSecurity .csrf(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) @@ -57,13 +62,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws userInfoEndpointConfig.userService(customOAuth2UserService)) .failureHandler(customOAuthFailureHandler) .successHandler(customOAuthSuccessHandler) - ).build(); + ); -// TODO JWT 인증 필터가 인증 요청 처리, JWT 인증 필터를 UsernamePasswordAuthenticationFilter 앞에 추가 -// return httpSecurity -// .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) -// .addFilterBefore(jwtExceptionFilter, JwtAuthFilter.class) -// .build(); + return httpSecurity + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtExceptionFilter, JwtAuthFilter.class) + .build(); } } diff --git a/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java b/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java new file mode 100644 index 000000000..509c513ce --- /dev/null +++ b/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java @@ -0,0 +1,293 @@ +package com.somemore.auth.jwt.service; + +import com.somemore.IntegrationTestSupport; +import com.somemore.auth.jwt.domain.EncodedToken; +import com.somemore.auth.jwt.domain.TokenType; +import com.somemore.auth.jwt.domain.UserRole; +import com.somemore.auth.jwt.exception.JwtErrorType; +import com.somemore.auth.jwt.exception.JwtException; +import com.somemore.auth.jwt.refresh.domain.RefreshToken; +import com.somemore.auth.jwt.refresh.manager.RefreshTokenManager; +import com.somemore.auth.jwt.validator.JwtValidator; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpServletResponse; + +import javax.crypto.SecretKey; +import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; + + +class JwtServiceTest extends IntegrationTestSupport { + + @Autowired + private JwtService jwtService; + @Autowired + private JwtValidator jwtValidator; + @Autowired + private SecretKey secretKey; + @Autowired + private RefreshTokenManager refreshTokenManager; + + @DisplayName("토큰이 올바르게 생성된다") + @Test + void generateAndValidateToken() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + TokenType tokenType = TokenType.ACCESS; + + // when + EncodedToken token = jwtService.generateToken(userId, role.name(), tokenType); + + // then + Claims claims = jwtService.getClaims(token); + assertThat(claims.get("id", String.class)).isEqualTo(userId); + assertThat(claims.get("role", String.class)).isEqualTo(role.name()); + assertThat(claims.getExpiration()).isNotNull(); + } + + @DisplayName("토큰 만료 기간이 정확히 설정되어야 한다") + @Test + void tokenExpirationPeriodIsExact() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + + // when + EncodedToken accessToken = jwtService.generateToken(userId, role.name(), TokenType.ACCESS); + EncodedToken refreshToken = jwtService.generateToken(userId, role.name(), TokenType.REFRESH); + + // then + Claims accessClaims = jwtService.getClaims(accessToken); + Claims refreshClaims = jwtService.getClaims(refreshToken); + + long accessTokenDuration = accessClaims.getExpiration().getTime() - accessClaims.getIssuedAt().getTime(); + long refreshTokenDuration = refreshClaims.getExpiration().getTime() - refreshClaims.getIssuedAt().getTime(); + + assertThat(accessTokenDuration).isEqualTo(TokenType.ACCESS.getPeriod()); + assertThat(refreshTokenDuration).isEqualTo(TokenType.REFRESH.getPeriod()); + } + + @DisplayName("동일한 사용자로 여러 토큰 생성 시 서로 다른 값이어야 한다") + @Test + void multipleTokensForSameUserAreDifferent() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + + // when + EncodedToken token1 = jwtService.generateToken(userId, role.name(), TokenType.ACCESS); + EncodedToken token2 = jwtService.generateToken(userId, role.name(), TokenType.ACCESS); + + // then + assertThat(token1.value()).isNotEqualTo(token2.value()); + } + + @DisplayName("만료된 엑세스 토큰은 리프레시 토큰이 유효하다면 갱신된다") + @Test + void verifyAndRefreshExpiredToken() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + EncodedToken expiredAccessToken = createExpiredToken(userId, role); + createAndSaveRefreshToken(userId, expiredAccessToken, Instant.now().plusMillis(TokenType.REFRESH.getPeriod())); + + MockHttpServletResponse mockResponse = new MockHttpServletResponse(); + + // when + jwtService.processAccessToken(expiredAccessToken, mockResponse); + + // then + assertRefreshedAccessToken(mockResponse); + } + + @DisplayName("만료된 엑세스 토큰은 리프레시 토큰이 유효하지 않다면 갱신되지 않고 예외가 발생한다") + @Test + void throwExceptionWhenRefreshTokenIsInvalid() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + EncodedToken expiredAccessToken = createExpiredToken(userId, role); + + EncodedToken expiredRefreshToken = createExpiredToken(userId, role); + RefreshToken refreshToken = new RefreshToken(userId, expiredAccessToken, expiredRefreshToken); + refreshTokenManager.save(refreshToken); + + // when + // then + MockHttpServletResponse mockResponse = new MockHttpServletResponse(); + + assertThatThrownBy(() -> jwtService.processAccessToken(expiredAccessToken, mockResponse)) + .isInstanceOf(JwtException.class) + .hasMessage(JwtErrorType.EXPIRED_TOKEN.getMessage()); + } + + @DisplayName("만료된 엑세스 토큰은 리프레시 토큰이 존재하지 않는다면 갱신되지 않고 예외가 발생한다") + @Test + void throwExceptionWhenRefreshTokenIsMissing() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + EncodedToken expiredAccessToken = createExpiredToken(userId, role); + + // when + // then + MockHttpServletResponse mockResponse = new MockHttpServletResponse(); + + assertThatThrownBy(() -> jwtService.processAccessToken(expiredAccessToken, mockResponse)) + .isInstanceOf(JwtException.class) + .hasMessage(JwtErrorType.EXPIRED_TOKEN.getMessage()); + } + + @DisplayName("리프레시된 AccessToken은 쿠키에 올바르게 저장된다") + @Test + void refreshedAccessTokenIsSetInCookie() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + + EncodedToken expiredAccessToken = createExpiredToken(userId, role); + createAndSaveRefreshToken(userId, expiredAccessToken, Instant.now().plusMillis(TokenType.REFRESH.getPeriod())); + + MockHttpServletResponse mockResponse = new MockHttpServletResponse(); + + // when + jwtService.processAccessToken(expiredAccessToken, mockResponse); + + // then + String cookieHeader = mockResponse.getHeader("Set-Cookie"); + assertThat(cookieHeader).contains("ACCESS="); + assertThat(cookieHeader).contains("HttpOnly"); + assertThat(cookieHeader).contains("Secure"); + } + + @DisplayName("기존 RefreshToken이 갱신된다") + @Test + void refreshTokenIsUpdated() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + + EncodedToken expiredAccessToken = createExpiredToken(userId, role); + RefreshToken oldRefreshToken = createAndSaveRefreshToken(userId, expiredAccessToken, Instant.now().plusMillis(TokenType.REFRESH.getPeriod())); + + EncodedToken newAccessToken = jwtService.generateToken(userId, role.name(), TokenType.ACCESS); + RefreshToken newRefreshToken = createAndSaveRefreshToken(userId, newAccessToken, Instant.now().plusMillis(TokenType.REFRESH.getPeriod())); + + // when + // then + assertThatThrownBy(() -> refreshTokenManager.findRefreshToken(expiredAccessToken)) + .isInstanceOf(JwtException.class) + .hasMessage(JwtErrorType.EXPIRED_TOKEN.getMessage()); + + assertThat(newRefreshToken.getAccessToken()).isEqualTo(newAccessToken.value()); + assertThat(newRefreshToken.getRefreshToken()).isNotEqualTo(oldRefreshToken.getRefreshToken()); + } + + + @DisplayName("잘못된 JWT 토큰은 예외가 발생한다") + @Test + void invalidTokenThrowsJwtException() { + // given + String invalidToken = "invalid.token.value"; + EncodedToken encodedToken = new EncodedToken(invalidToken); + + // when + // then + assertThatThrownBy(() -> jwtValidator.validateToken(encodedToken)) + .isInstanceOf(JwtException.class) + .hasMessage(JwtErrorType.UNKNOWN_ERROR.getMessage()); + } + + @DisplayName("만료된 JWT 토큰은 예외가 발생한다") + @Test + void expiredTokenThrowsJwtException() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + EncodedToken expiredAccessToken = createExpiredToken(userId, role); + + // when + // then + assertThatThrownBy(() -> jwtValidator.validateToken(expiredAccessToken)) + .isInstanceOf(JwtException.class) + .hasMessage(JwtErrorType.EXPIRED_TOKEN.getMessage()); + } + + @DisplayName("RefreshToken이 존재하지 않으면 예외가 발생한다") + @Test + void refreshTokenNotFoundThrowsJwtException() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + EncodedToken expiredAccessToken = createExpiredToken(userId, role); + + // when + // then + assertThatThrownBy(() -> jwtService.processAccessToken(expiredAccessToken, new MockHttpServletResponse())) + .isInstanceOf(JwtException.class) + .hasMessage(JwtErrorType.EXPIRED_TOKEN.getMessage()); + } + + private EncodedToken createExpiredToken(String userId, UserRole role) { + Claims claims = buildClaims(userId, role); + + Instant now = Instant.now(); + Instant expiration = now.plusMillis(-1); // 과거 + + return new EncodedToken(Jwts.builder() + .claims(claims) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiration)) + .signWith(secretKey, Jwts.SIG.HS256) + .compact()); + } + + private RefreshToken createAndSaveRefreshToken(String userId, EncodedToken accessToken, Instant expiration) { + Claims claims = buildClaims(userId, UserRole.VOLUNTEER); + Instant now = Instant.now(); + Instant refreshExpiration = now.plusMillis(TokenType.REFRESH.getPeriod()); + String uniqueId = UUID.randomUUID().toString(); // jti + + RefreshToken refreshToken = new RefreshToken( + userId, + accessToken, + new EncodedToken(Jwts.builder() + .claims(claims) + .id(uniqueId) + .issuedAt(Date.from(now)) + .expiration(Date.from(refreshExpiration)) + .signWith(secretKey, Jwts.SIG.HS256) + .compact())); + + refreshTokenManager.save(refreshToken); + + return refreshToken; + } + + private Claims buildClaims(String userId, UserRole role) { + return Jwts.claims() + .add("id", userId) + .add("role", role) + .build(); + } + + private void assertRefreshedAccessToken(MockHttpServletResponse mockResponse) { + String cookie = mockResponse.getHeader("Set-Cookie"); + assertThat(cookie).isNotNull(); + assertThat(cookie).contains(TokenType.ACCESS.name()); + + EncodedToken refreshedAccessToken = new EncodedToken( + cookie.split(";")[0].substring("ACCESS=".length())); + assertThatCode(() -> jwtValidator.validateToken(refreshedAccessToken)) + .doesNotThrowAnyException(); + } + +}