From c240dc99ba06edb91381ec98f08f86790a8d6431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=80=ED=86=A0?= <57691173+apptie@users.noreply.github.com> Date: Thu, 10 Aug 2023 13:29:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20#252=20=ED=86=A0=ED=81=B0=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20api=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#255)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: accessToken 검증 기능 추가 * feat: accessToken 검증 api 추가 * style: final 추가 * test: 변수 네이밍 변경 * refactor: 토큰 타입 상수 네이밍 변경 * refactor: try-catch 변수 네이밍 수정 * style: 메서드 순서 변경 --- .../application/AuthenticationService.java | 6 ++- .../infrastructure/jwt/JwtDecoder.java | 14 +++---- .../AuthenticationController.java | 15 +++++++- .../dto/response/ValidatedTokenResponse.java | 4 ++ .../AuthenticationServiceTest.java | 38 +++++++++++++++++++ .../AuthenticationControllerTest.java | 34 +++++++++++++++++ 6 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 backend/ddang/src/main/java/com/ddang/ddang/authentication/presentation/dto/response/ValidatedTokenResponse.java diff --git a/backend/ddang/src/main/java/com/ddang/ddang/authentication/application/AuthenticationService.java b/backend/ddang/src/main/java/com/ddang/ddang/authentication/application/AuthenticationService.java index 8989d2461..77d473377 100644 --- a/backend/ddang/src/main/java/com/ddang/ddang/authentication/application/AuthenticationService.java +++ b/backend/ddang/src/main/java/com/ddang/ddang/authentication/application/AuthenticationService.java @@ -68,7 +68,6 @@ private TokenDto convertTokenDto(final User persistUser) { return new TokenDto(accessToken, refreshToken); } - @Transactional(readOnly = true) public TokenDto refreshToken(final String refreshToken) { final PrivateClaims privateClaims = tokenDecoder.decode(TokenType.REFRESH, refreshToken) .orElseThrow( @@ -82,4 +81,9 @@ public TokenDto refreshToken(final String refreshToken) { return new TokenDto(accessToken, refreshToken); } + + public boolean validateToken(final String accessToken) { + return tokenDecoder.decode(TokenType.ACCESS, accessToken) + .isPresent(); + } } diff --git a/backend/ddang/src/main/java/com/ddang/ddang/authentication/infrastructure/jwt/JwtDecoder.java b/backend/ddang/src/main/java/com/ddang/ddang/authentication/infrastructure/jwt/JwtDecoder.java index 5b1162b2e..0f7f29d38 100644 --- a/backend/ddang/src/main/java/com/ddang/ddang/authentication/infrastructure/jwt/JwtDecoder.java +++ b/backend/ddang/src/main/java/com/ddang/ddang/authentication/infrastructure/jwt/JwtDecoder.java @@ -17,7 +17,7 @@ @RequiredArgsConstructor public class JwtDecoder implements TokenDecoder { - private static final String TOKEN_TYPE = "Bearer "; + private static final String BEARER_TOKEN_PREFIX = "Bearer "; private static final String CLAIM_NAME = "userId"; private static final int BEARER_END_INDEX = 7; @@ -42,7 +42,7 @@ private void validateBearerToken(final String token) { } private void validateTokenType(final String tokenType) { - if (!TOKEN_TYPE.equals(tokenType)) { + if (!BEARER_TOKEN_PREFIX.equals(tokenType)) { throw new InvalidTokenException("Bearer 타입이 아닙니다."); } } @@ -58,16 +58,16 @@ private Optional parse(final TokenType tokenType, final String token) { .parseClaimsJws(findPureToken(token)) .getBody() ); - } catch (JwtException e) { + } catch (final JwtException ignored) { return Optional.empty(); } } - private PrivateClaims convert(final Claims claims) { - return new PrivateClaims(claims.get(CLAIM_NAME, Long.class)); + private String findPureToken(final String token) { + return token.substring(BEARER_TOKEN_PREFIX.length()); } - private String findPureToken(final String token) { - return token.substring(TOKEN_TYPE.length()); + private PrivateClaims convert(final Claims claims) { + return new PrivateClaims(claims.get(CLAIM_NAME, Long.class)); } } diff --git a/backend/ddang/src/main/java/com/ddang/ddang/authentication/presentation/AuthenticationController.java b/backend/ddang/src/main/java/com/ddang/ddang/authentication/presentation/AuthenticationController.java index 8f57a49aa..24376b9b7 100644 --- a/backend/ddang/src/main/java/com/ddang/ddang/authentication/presentation/AuthenticationController.java +++ b/backend/ddang/src/main/java/com/ddang/ddang/authentication/presentation/AuthenticationController.java @@ -6,11 +6,15 @@ import com.ddang.ddang.authentication.presentation.dto.request.AccessTokenRequest; import com.ddang.ddang.authentication.presentation.dto.request.RefreshTokenRequest; import com.ddang.ddang.authentication.presentation.dto.response.TokenResponse; +import com.ddang.ddang.authentication.presentation.dto.response.ValidatedTokenResponse; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -23,7 +27,7 @@ public class AuthenticationController { @PostMapping("/login/{oauth2Type}") public ResponseEntity validate( - @PathVariable Oauth2Type oauth2Type, + @PathVariable final Oauth2Type oauth2Type, @RequestBody final AccessTokenRequest request ) { final TokenDto tokenDto = authenticationService.login(oauth2Type, request.accessToken()); @@ -37,4 +41,13 @@ public ResponseEntity refreshToken(@RequestBody final RefreshTokenReques return ResponseEntity.ok(TokenResponse.from(tokenDto)); } + + @GetMapping("/validate-token") + public ResponseEntity validateToken( + @RequestHeader(HttpHeaders.AUTHORIZATION) final String accessToken + ) { + final boolean validated = authenticationService.validateToken(accessToken); + + return ResponseEntity.ok(new ValidatedTokenResponse(validated)); + } } diff --git a/backend/ddang/src/main/java/com/ddang/ddang/authentication/presentation/dto/response/ValidatedTokenResponse.java b/backend/ddang/src/main/java/com/ddang/ddang/authentication/presentation/dto/response/ValidatedTokenResponse.java new file mode 100644 index 000000000..ac9f24572 --- /dev/null +++ b/backend/ddang/src/main/java/com/ddang/ddang/authentication/presentation/dto/response/ValidatedTokenResponse.java @@ -0,0 +1,4 @@ +package com.ddang.ddang.authentication.presentation.dto.response; + +public record ValidatedTokenResponse(boolean validated) { +} diff --git a/backend/ddang/src/test/java/com/ddang/ddang/authentication/application/AuthenticationServiceTest.java b/backend/ddang/src/test/java/com/ddang/ddang/authentication/application/AuthenticationServiceTest.java index 67c9a7f00..3f7c3c214 100644 --- a/backend/ddang/src/test/java/com/ddang/ddang/authentication/application/AuthenticationServiceTest.java +++ b/backend/ddang/src/test/java/com/ddang/ddang/authentication/application/AuthenticationServiceTest.java @@ -1,5 +1,6 @@ package com.ddang.ddang.authentication.application; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; @@ -197,4 +198,41 @@ void setUp( .isInstanceOf(InvalidTokenException.class) .hasMessage("Bearer 타입이 아닙니다."); } + + @Test + void 유효한_accessToken을_검증하면_참을_반환한다() { + // given + final Map privateClaims = Map.of("userId", 1L); + final String accessToken = "Bearer " + tokenEncoder.encode( + LocalDateTime.now(), + TokenType.ACCESS, + privateClaims + ); + + // when + final boolean actual = authenticationService.validateToken(accessToken); + + // then + assertThat(actual).isTrue(); + } + + @Test + void 만료된_accessToken을_검증하면_거짓을_반환한다() { + // given + final Instant instant = Instant.parse("2000-08-10T15:30:00Z"); + final LocalDateTime expiredPublishTime = instant.atZone(ZoneId.of("UTC")).toLocalDateTime(); + + final Map privateClaims = Map.of("userId", 1L); + final String accessToken = "Bearer " + tokenEncoder.encode( + expiredPublishTime, + TokenType.ACCESS, + privateClaims + ); + + // when + final boolean actual = authenticationService.validateToken(accessToken); + + // then + assertThat(actual).isFalse(); + } } diff --git a/backend/ddang/src/test/java/com/ddang/ddang/authentication/presentation/AuthenticationControllerTest.java b/backend/ddang/src/test/java/com/ddang/ddang/authentication/presentation/AuthenticationControllerTest.java index 5e450d5f8..b0a9f5b87 100644 --- a/backend/ddang/src/test/java/com/ddang/ddang/authentication/presentation/AuthenticationControllerTest.java +++ b/backend/ddang/src/test/java/com/ddang/ddang/authentication/presentation/AuthenticationControllerTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -29,6 +30,7 @@ import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.FilterType; import org.springframework.format.support.FormattingConversionService; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -164,4 +166,36 @@ void setUp() { jsonPath("$.message").exists() ); } + + @Test + void 유효한_accessToken을_검증하면_참을_반환한다() throws Exception { + // given + given(authenticationService.validateToken(anyString())).willReturn(true); + + // when & then + mockMvc.perform(get("/oauth2/validate-token") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer accessToken") + ) + .andExpectAll( + status().isOk(), + jsonPath("$.validated").value(true) + ); + } + + @Test + void 만료된_accessToken을_검증하면_거짓을_반환한다() throws Exception { + // given + given(authenticationService.validateToken(anyString())).willReturn(false); + + // when & then + mockMvc.perform(get("/oauth2/validate-token") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer invalidAccessToken") + ) + .andExpectAll( + status().isOk(), + jsonPath("$.validated").value(false) + ); + } }