From fe0bbac0c35bc62c443e11563d7c549ad5f60e29 Mon Sep 17 00:00:00 2001 From: Jo In Hyeok Date: Mon, 29 Jul 2024 09:19:11 +0900 Subject: [PATCH] =?UTF-8?q?Feat(Auth):=20reissue,=20logout=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Chore(*): 임시 커밋 * Feat(Auth): reissue API 구현 * Feat(Auth): logout 구현 * Feat(Auth): reissue요청시 jwtFilter 검증 분기 처리 * Feat(*): 사용안하는 메소드 제거 * Feat(*): 사용안하는 타입 void 수정 * Feat(*): print 제거 * Refactor(*): jwt 검증, claim 조회 분리 --- .../jabiseo/auth/application/JwtHandler.java | 45 +++++---- .../application/usecase/LogoutUseCase.java | 9 +- .../application/usecase/ReissueUseCase.java | 33 ++++++- .../auth/controller/AuthController.java | 12 ++- .../com/jabiseo/auth/dto/ReissueRequest.java | 6 ++ .../com/jabiseo/auth/dto/ReissueResponse.java | 5 + .../security/JwtAuthenticationFilter.java | 17 +++- jabiseo-api/src/main/resources/api.yml | 2 +- .../usecase/ReissueUseCaseTest.java | 95 +++++++++++++++++++ .../security/JwtAuthenticationFilterTest.java | 28 +++++- .../exception/AuthenticationErrorCode.java | 3 +- .../jabiseo/cache/RedisCacheRepository.java | 17 +++- 12 files changed, 236 insertions(+), 36 deletions(-) create mode 100644 jabiseo-api/src/main/java/com/jabiseo/auth/dto/ReissueRequest.java create mode 100644 jabiseo-api/src/main/java/com/jabiseo/auth/dto/ReissueResponse.java create mode 100644 jabiseo-api/src/test/java/com/jabiseo/auth/application/usecase/ReissueUseCaseTest.java diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/application/JwtHandler.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/JwtHandler.java index 3c50564..b7acd7b 100644 --- a/jabiseo-api/src/main/java/com/jabiseo/auth/application/JwtHandler.java +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/JwtHandler.java @@ -3,6 +3,7 @@ import com.jabiseo.auth.exception.AuthenticationBusinessException; import com.jabiseo.auth.exception.AuthenticationErrorCode; import com.jabiseo.member.domain.Member; +import io.jsonwebtoken.ClaimJwtException; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; @@ -21,7 +22,7 @@ public class JwtHandler { private final Key refreshKey; private final Integer accessExpiredMin; private final Integer refreshExpiredDay; - private final String APP_ISSUER = "jabiseo"; + private static final String APP_ISSUER = "jabiseo"; public JwtHandler(JwtProperty jwtProperty) { byte[] accessEncodeByte = Base64.getEncoder().encode((jwtProperty.getAccessKey().getBytes())); @@ -36,7 +37,6 @@ public JwtHandler(JwtProperty jwtProperty) { public String createAccessToken(Member member) { Instant accessExpiredTime = Instant.now() .plus(this.accessExpiredMin, ChronoUnit.MINUTES); - Map payload = new HashMap<>(); return Jwts.builder() @@ -57,14 +57,13 @@ public String createRefreshToken() { .compact(); } - - public boolean validateAccessToken(String token) { + public void validateAccessToken(String accessToken){ try { Jwts.parserBuilder() .setSigningKey(accessKey) .build() - .parseClaimsJws(token); - return true; + .parseClaimsJws(accessToken) + .getBody(); } catch (ExpiredJwtException e) { throw new AuthenticationBusinessException(AuthenticationErrorCode.EXPIRED_APP_JWT); } catch (Exception e) { @@ -72,6 +71,21 @@ public boolean validateAccessToken(String token) { } } + public void validateAccessTokenNotCheckExpired(String accessToken){ + try { + Jwts.parserBuilder() + .setSigningKey(accessKey) + .build() + .parseClaimsJws(accessToken) + .getBody(); + } catch (ExpiredJwtException e) { + return; + } catch (Exception e) { + throw new AuthenticationBusinessException(AuthenticationErrorCode.INVALID_APP_JWT); + } + } + + public void validateRefreshToken(String refreshToken) { try { Jwts.parserBuilder() @@ -86,26 +100,19 @@ public void validateRefreshToken(String refreshToken) { } - public Claims getClaimFromExpiredAccessToken(String accessToken) { + public Claims getClaimsFromAccessToken(String token) { try { - return Jwts.parserBuilder() + return Jwts + .parserBuilder() .setSigningKey(accessKey) .build() - .parseClaimsJws(accessToken) + .parseClaimsJws(token) .getBody(); - } catch (ExpiredJwtException e) { + } catch (ClaimJwtException e) { + // 기존 검증에서 처리후 가져오는 동작 return e.getClaims(); } catch (Exception e) { throw new AuthenticationBusinessException(AuthenticationErrorCode.INVALID_APP_JWT); } } - - public Claims getClaimsFromAccessToken(String token) { - return Jwts - .parserBuilder() - .setSigningKey(accessKey) - .build() - .parseClaimsJws(token) - .getBody(); - } } diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/LogoutUseCase.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/LogoutUseCase.java index 4008f47..1b99c00 100644 --- a/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/LogoutUseCase.java +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/LogoutUseCase.java @@ -1,10 +1,17 @@ package com.jabiseo.auth.application.usecase; +import com.jabiseo.cache.RedisCacheRepository; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @Service +@RequiredArgsConstructor public class LogoutUseCase { - public void execute() { + + private final RedisCacheRepository redisCacheRepository; + + public void execute(String memberId) { + redisCacheRepository.deleteToken(memberId); } } diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/ReissueUseCase.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/ReissueUseCase.java index 06124c9..97fa58d 100644 --- a/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/ReissueUseCase.java +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/ReissueUseCase.java @@ -1,13 +1,42 @@ package com.jabiseo.auth.application.usecase; +import com.jabiseo.auth.application.JwtHandler; import com.jabiseo.auth.dto.LoginResponse; +import com.jabiseo.auth.dto.ReissueRequest; +import com.jabiseo.auth.dto.ReissueResponse; +import com.jabiseo.auth.exception.AuthenticationBusinessException; +import com.jabiseo.auth.exception.AuthenticationErrorCode; +import com.jabiseo.cache.RedisCacheRepository; +import com.jabiseo.member.domain.Member; +import com.jabiseo.member.domain.MemberRepository; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service +@RequiredArgsConstructor +@Transactional(readOnly = true) public class ReissueUseCase { - public LoginResponse reissue(String refreshToken) { - return new LoginResponse("access_token", "refresh_token"); + private final MemberRepository memberRepository; + private final RedisCacheRepository redisCacheRepository; + private final JwtHandler jwtHandler; + + public ReissueResponse execute(ReissueRequest request, String memberId) { + Member member = memberRepository.getReferenceById(memberId); + + jwtHandler.validateRefreshToken(request.refreshToken()); + + String savedToken = redisCacheRepository.findToken(memberId) + .orElseThrow(() -> new AuthenticationBusinessException(AuthenticationErrorCode.REQUIRE_LOGIN)); + + if (!savedToken.equals(request.refreshToken())) { + throw new AuthenticationBusinessException(AuthenticationErrorCode.NOT_MATCH_REFRESH); + } + + String accessToken = jwtHandler.createAccessToken(member); + return new ReissueResponse(accessToken); } + } diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/controller/AuthController.java b/jabiseo-api/src/main/java/com/jabiseo/auth/controller/AuthController.java index 26f15ca..6432c62 100644 --- a/jabiseo-api/src/main/java/com/jabiseo/auth/controller/AuthController.java +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/controller/AuthController.java @@ -6,6 +6,10 @@ import com.jabiseo.auth.application.usecase.LogoutUseCase; import com.jabiseo.auth.application.usecase.ReissueUseCase; import com.jabiseo.auth.application.usecase.WithdrawUseCase; +import com.jabiseo.auth.dto.ReissueRequest; +import com.jabiseo.auth.dto.ReissueResponse; +import com.jabiseo.config.auth.AuthMember; +import com.jabiseo.config.auth.AuthenticatedMember; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -34,14 +38,14 @@ public ResponseEntity login(@Valid @RequestBody LoginRequest logi } @PostMapping("/reissue") - public ResponseEntity reissue(String refreshToken) { - LoginResponse result = reissueUseCase.reissue(refreshToken); + public ResponseEntity reissue(@Valid @RequestBody ReissueRequest request, @AuthenticatedMember AuthMember member) { + ReissueResponse result = reissueUseCase.execute(request, member.getMemberId()); return ResponseEntity.ok(result); } @PostMapping("/logout") - public ResponseEntity logout() { - logoutUseCase.execute(); + public ResponseEntity logout(@AuthenticatedMember AuthMember member) { + logoutUseCase.execute(member.getMemberId()); return ResponseEntity.noContent().build(); } diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/dto/ReissueRequest.java b/jabiseo-api/src/main/java/com/jabiseo/auth/dto/ReissueRequest.java new file mode 100644 index 0000000..244da78 --- /dev/null +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/dto/ReissueRequest.java @@ -0,0 +1,6 @@ +package com.jabiseo.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ReissueRequest(@NotBlank String refreshToken) { +} diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/dto/ReissueResponse.java b/jabiseo-api/src/main/java/com/jabiseo/auth/dto/ReissueResponse.java new file mode 100644 index 0000000..bc073d0 --- /dev/null +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/dto/ReissueResponse.java @@ -0,0 +1,5 @@ +package com.jabiseo.auth.dto; + +public record ReissueResponse(String accessToken) { + +} diff --git a/jabiseo-api/src/main/java/com/jabiseo/common/security/JwtAuthenticationFilter.java b/jabiseo-api/src/main/java/com/jabiseo/common/security/JwtAuthenticationFilter.java index 84dc535..8913621 100644 --- a/jabiseo-api/src/main/java/com/jabiseo/common/security/JwtAuthenticationFilter.java +++ b/jabiseo-api/src/main/java/com/jabiseo/common/security/JwtAuthenticationFilter.java @@ -24,22 +24,31 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtHandler jwtHandler; - private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String AUTHORIZATION_HEADER = "Authorization"; private static final String HEADER_PREFIX = "Bearer "; - + private static final String REISSUE_REQUEST = "/api/auth/reissue"; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = extractTokenFromRequest(request); - if (StringUtils.hasText(token) && jwtHandler.validateAccessToken(token)) { - Claims claims = jwtHandler.getClaimsFromAccessToken(token); + if (StringUtils.hasText(token)) { + Claims claims = getClaimsFromToken(token, request); setAuthenticationToContext(claims); } filterChain.doFilter(request, response); } + private Claims getClaimsFromToken(String token, HttpServletRequest request) { + if (request.getRequestURI().equals(REISSUE_REQUEST)) { + jwtHandler.validateAccessTokenNotCheckExpired(token); + } else { + jwtHandler.validateAccessToken(token); + } + return jwtHandler.getClaimsFromAccessToken(token); + } + private void setAuthenticationToContext(Claims jwtClaim) { String memberId = jwtClaim.getSubject(); Set authorities = new HashSet<>(); diff --git a/jabiseo-api/src/main/resources/api.yml b/jabiseo-api/src/main/resources/api.yml index 31b2225..c5a1caf 100644 --- a/jabiseo-api/src/main/resources/api.yml +++ b/jabiseo-api/src/main/resources/api.yml @@ -38,7 +38,7 @@ management: include: "*" jwt: - access-expired-min: 6000 + access-expired-min: 1 --- spring: diff --git a/jabiseo-api/src/test/java/com/jabiseo/auth/application/usecase/ReissueUseCaseTest.java b/jabiseo-api/src/test/java/com/jabiseo/auth/application/usecase/ReissueUseCaseTest.java new file mode 100644 index 0000000..383a245 --- /dev/null +++ b/jabiseo-api/src/test/java/com/jabiseo/auth/application/usecase/ReissueUseCaseTest.java @@ -0,0 +1,95 @@ +package com.jabiseo.auth.application.usecase; + +import com.jabiseo.auth.application.JwtHandler; +import com.jabiseo.auth.dto.ReissueRequest; +import com.jabiseo.auth.dto.ReissueResponse; +import com.jabiseo.auth.exception.AuthenticationBusinessException; +import com.jabiseo.auth.exception.AuthenticationErrorCode; +import com.jabiseo.cache.RedisCacheRepository; +import com.jabiseo.member.domain.Member; +import com.jabiseo.member.domain.MemberRepository; +import fixture.MemberFixture; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class ReissueUseCaseTest { + + @InjectMocks + ReissueUseCase reissueUseCase; + + @Mock + MemberRepository memberRepository; + + @Mock + RedisCacheRepository redisCacheRepository; + + @Mock + JwtHandler jwtHandler; + + ReissueRequest request; + + @BeforeEach + void setUp() { + request = new ReissueRequest("refresh"); + } + + @Test + @DisplayName("저장된 토큰이 없는 경우 예외를 반환한다") + void savedTokenIsNullThrownException() { + //given + String memberId = "id"; + given(memberRepository.getReferenceById(memberId)).willReturn(MemberFixture.createMember(memberId)); + given(redisCacheRepository.findToken(memberId)).willReturn(Optional.empty()); + + //when then + assertThatThrownBy(() -> reissueUseCase.execute(request, memberId)) + .isInstanceOf(AuthenticationBusinessException.class); + } + + @Test + @DisplayName("다른 refreshToken으로 요청하면 예외를 반환한다") + void otherTokenRequestThrownException() { + //given + String memberId = "id"; + String otherToken = "tokens"; + given(memberRepository.getReferenceById(memberId)).willReturn(MemberFixture.createMember(memberId)); + given(redisCacheRepository.findToken(memberId)).willReturn(Optional.of(otherToken)); + + //when then + assertThatThrownBy(() -> reissueUseCase.execute(request, memberId)) + .isInstanceOf(AuthenticationBusinessException.class) + .hasMessage(AuthenticationErrorCode.NOT_MATCH_REFRESH.getMessage()); + } + + @Test + @DisplayName("정상 요청의 경우 새로운 access Token을 발급한다.") + void requestSuccessReturnNewAccessToken(){ + //given + String memberId = "id"; + Member member = MemberFixture.createMember(memberId); + String newAccessToken = "accessToken"; + given(memberRepository.getReferenceById(memberId)).willReturn(member); + given(redisCacheRepository.findToken(memberId)).willReturn(Optional.of(request.refreshToken())); + given(jwtHandler.createAccessToken(member)).willReturn(newAccessToken); + + //when + ReissueResponse execute = reissueUseCase.execute(request, memberId); + + //then + assertThat(execute.accessToken()).isEqualTo(newAccessToken); + } +} \ No newline at end of file diff --git a/jabiseo-api/src/test/java/com/jabiseo/common/security/JwtAuthenticationFilterTest.java b/jabiseo-api/src/test/java/com/jabiseo/common/security/JwtAuthenticationFilterTest.java index 407808d..6d021f3 100644 --- a/jabiseo-api/src/test/java/com/jabiseo/common/security/JwtAuthenticationFilterTest.java +++ b/jabiseo-api/src/test/java/com/jabiseo/common/security/JwtAuthenticationFilterTest.java @@ -64,6 +64,7 @@ void containsInvalidTokenPassProcess(String headerValue) throws ServletException jwtAuthenticationFilter.doFilterInternal(request, response, chain); //then + verify(jwtHandler, never()).getClaimsFromAccessToken(any()); verify(jwtHandler, never()).validateAccessToken(any()); verify(chain, times(1)).doFilter(request, response); } @@ -76,7 +77,6 @@ void containsTokenStartJwtValidating() throws ServletException, IOException { String headerValue = "Bearer " + token; request.addHeader("Authorization", headerValue); - given(jwtHandler.validateAccessToken(token)).willReturn(true); given(jwtHandler.getClaimsFromAccessToken(token)).willReturn(claims); // when @@ -84,6 +84,7 @@ void containsTokenStartJwtValidating() throws ServletException, IOException { //then verify(jwtHandler, times(1)).validateAccessToken(token); + verify(jwtHandler, times(0)).validateAccessTokenNotCheckExpired(token); } @Test @@ -96,7 +97,6 @@ void jwtValidSuccessSaveAuthenticationInfo() throws ServletException, IOExceptio String memberId = "memberId"; - given(jwtHandler.validateAccessToken(token)).willReturn(true); given(claims.getSubject()).willReturn(memberId); given(jwtHandler.getClaimsFromAccessToken(token)).willReturn(claims); @@ -109,5 +109,29 @@ void jwtValidSuccessSaveAuthenticationInfo() throws ServletException, IOExceptio assertThat(authentication.getPrincipal().toString()).isEqualTo(memberId); } + @Test + @DisplayName("reissue 요청 시 expired 검사안하고 Claim을 가져와 context에 저장한다.") + void reissueNotCheckExpired() throws ServletException, IOException { + //given + String token = "tokens"; + String headerValue = "Bearer " + token; + request.addHeader("Authorization", headerValue); + request.setRequestURI("/api/auth/reissue"); + String memberId = "memberId"; + + given(claims.getSubject()).willReturn(memberId); + given(jwtHandler.getClaimsFromAccessToken(token)).willReturn(claims); + + //when + jwtAuthenticationFilter.doFilterInternal(request, response, chain); + + //then + verify(jwtHandler, never()).validateAccessToken(token); + verify(jwtHandler, times(1)).validateAccessTokenNotCheckExpired(token); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + assertThat(authentication).isNotNull(); + assertThat(authentication.getPrincipal().toString()).isEqualTo(memberId); + } + } \ No newline at end of file diff --git a/jabiseo-domain/src/main/java/com/jabiseo/auth/exception/AuthenticationErrorCode.java b/jabiseo-domain/src/main/java/com/jabiseo/auth/exception/AuthenticationErrorCode.java index d6ac919..ffd8912 100644 --- a/jabiseo-domain/src/main/java/com/jabiseo/auth/exception/AuthenticationErrorCode.java +++ b/jabiseo-domain/src/main/java/com/jabiseo/auth/exception/AuthenticationErrorCode.java @@ -10,7 +10,8 @@ public enum AuthenticationErrorCode implements ErrorCode { EXPIRED_APP_JWT("만료된 jwt 토큰 입니다", "AUTH_004", ErrorCode.UNAUTHORIZED), INVALID_APP_JWT("잘못된 jwt 토큰입니다", "AUTH_005", ErrorCode.UNAUTHORIZED), GET_JWK_FAIL("jwk 획득 실패", "AUTH_006", ErrorCode.INTERNAL_SERVER_ERROR), - REQUIRE_LOGIN("로그인이 필요합니다","AUTH007", ErrorCode.UNAUTHORIZED); + REQUIRE_LOGIN("로그인이 필요합니다","AUTH007", ErrorCode.UNAUTHORIZED), + NOT_MATCH_REFRESH("refresh token이 일치하지 않습니다", "AUTH_008", ErrorCode.UNAUTHORIZED); private final String message; private final String errorCode; diff --git a/jabiseo-infrastructure/src/main/java/com/jabiseo/cache/RedisCacheRepository.java b/jabiseo-infrastructure/src/main/java/com/jabiseo/cache/RedisCacheRepository.java index e91c454..5b565d6 100644 --- a/jabiseo-infrastructure/src/main/java/com/jabiseo/cache/RedisCacheRepository.java +++ b/jabiseo-infrastructure/src/main/java/com/jabiseo/cache/RedisCacheRepository.java @@ -11,6 +11,7 @@ import java.util.Arrays; import java.util.List; +import java.util.Optional; import java.util.concurrent.TimeUnit; @Component @@ -18,6 +19,7 @@ public class RedisCacheRepository { private final RedisTemplate redisStringTemplate; private final ValueOperations operation; + private static final String MEMBER_TOKEN_PREFIX = "member_token:"; private final ObjectMapper mapper = new ObjectMapper(); public RedisCacheRepository(RedisTemplate redisStringTemplate) { @@ -27,14 +29,25 @@ public RedisCacheRepository(RedisTemplate redisStringTemplate) { public void saveToken(String key, String value) { - operation.set(key, value); + operation.set(toMemberTokenKey(key), value); } + public Optional findToken(String key) { + String token = operation.get(toMemberTokenKey(key)); + return Optional.ofNullable(token); + } + + public void deleteToken(String key){ + operation.getAndDelete(toMemberTokenKey(key)); + } + + private String toMemberTokenKey(String id){ + return MEMBER_TOKEN_PREFIX + id; + } public void savePublicKey(String key, List publicKeys) { try { String publicKeyString = mapper.writeValueAsString(publicKeys); - // TODO: timeout 값 논의 필요 operation.set(key, publicKeyString, 1, TimeUnit.DAYS); } catch (JsonProcessingException e) {