diff --git a/src/main/java/com/seong/shoutlink/domain/auth/JwtProvider.java b/src/main/java/com/seong/shoutlink/domain/auth/JwtProvider.java index aac6876..a647e70 100644 --- a/src/main/java/com/seong/shoutlink/domain/auth/JwtProvider.java +++ b/src/main/java/com/seong/shoutlink/domain/auth/JwtProvider.java @@ -1,8 +1,11 @@ package com.seong.shoutlink.domain.auth; +import com.seong.shoutlink.domain.auth.service.response.ClaimsResponse; import com.seong.shoutlink.domain.auth.service.response.TokenResponse; import com.seong.shoutlink.domain.member.MemberRole; public interface JwtProvider { TokenResponse createToken(Long memberId, MemberRole memberRole); + + ClaimsResponse parseAccessToken(String accessToken); } diff --git a/src/main/java/com/seong/shoutlink/domain/auth/service/AuthService.java b/src/main/java/com/seong/shoutlink/domain/auth/service/AuthService.java index 778329e..29901ed 100644 --- a/src/main/java/com/seong/shoutlink/domain/auth/service/AuthService.java +++ b/src/main/java/com/seong/shoutlink/domain/auth/service/AuthService.java @@ -48,7 +48,7 @@ public CreateMemberResponse createMember(CreateMemberCommand command) { command.email(), passwordEncoder.encode(command.password()), command.nickname(), - MemberRole.USER); + MemberRole.ROLE_USER); return new CreateMemberResponse(memberRepository.save(member)); } diff --git a/src/main/java/com/seong/shoutlink/domain/auth/service/response/ClaimsResponse.java b/src/main/java/com/seong/shoutlink/domain/auth/service/response/ClaimsResponse.java new file mode 100644 index 0000000..e029c4a --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/auth/service/response/ClaimsResponse.java @@ -0,0 +1,8 @@ +package com.seong.shoutlink.domain.auth.service.response; + +import com.seong.shoutlink.domain.member.MemberRole; +import java.util.List; + +public record ClaimsResponse(Long memberId, MemberRole memberRole, List authorities) { + +} diff --git a/src/main/java/com/seong/shoutlink/domain/member/MemberRole.java b/src/main/java/com/seong/shoutlink/domain/member/MemberRole.java index 0eb562b..9979342 100644 --- a/src/main/java/com/seong/shoutlink/domain/member/MemberRole.java +++ b/src/main/java/com/seong/shoutlink/domain/member/MemberRole.java @@ -5,8 +5,8 @@ @Getter public enum MemberRole { - USER(Constants.ROLE_USER, List.of(Constants.ROLE_USER)), - ADMIN(Constants.ROLE_ADMIN, List.of(Constants.ROLE_USER, Constants.ROLE_ADMIN)); + ROLE_USER(Constants.ROLE_USER, List.of(Constants.ROLE_USER)), + ROLE_ADMIN(Constants.ROLE_ADMIN, List.of(Constants.ROLE_USER, Constants.ROLE_ADMIN)); private final String value; private final List authorities; diff --git a/src/main/java/com/seong/shoutlink/global/auth/authentication/Authentication.java b/src/main/java/com/seong/shoutlink/global/auth/authentication/Authentication.java new file mode 100644 index 0000000..d83e32f --- /dev/null +++ b/src/main/java/com/seong/shoutlink/global/auth/authentication/Authentication.java @@ -0,0 +1,12 @@ +package com.seong.shoutlink.global.auth.authentication; + +import java.util.List; + +public interface Authentication { + + Long getPrincipal(); + + List getAuthorities(); + + String getCredentials(); +} diff --git a/src/main/java/com/seong/shoutlink/global/auth/authentication/AuthenticationContext.java b/src/main/java/com/seong/shoutlink/global/auth/authentication/AuthenticationContext.java new file mode 100644 index 0000000..4facbf0 --- /dev/null +++ b/src/main/java/com/seong/shoutlink/global/auth/authentication/AuthenticationContext.java @@ -0,0 +1,23 @@ +package com.seong.shoutlink.global.auth.authentication; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class AuthenticationContext { + + private final ThreadLocal context = new ThreadLocal<>(); + + public void setAuthentication(Authentication authentication) { + context.set(authentication); + log.debug("[Auth] 인증 컨텍스트 설정됨"); + } + + public Authentication getAuthentication() { + return context.get(); + } + + public void releaseContext() { + context.remove(); + log.debug("[Auth] 인증 컨텍스트 소멸됨"); + } +} diff --git a/src/main/java/com/seong/shoutlink/global/auth/authentication/JwtAuthentication.java b/src/main/java/com/seong/shoutlink/global/auth/authentication/JwtAuthentication.java new file mode 100644 index 0000000..f1b7e5c --- /dev/null +++ b/src/main/java/com/seong/shoutlink/global/auth/authentication/JwtAuthentication.java @@ -0,0 +1,25 @@ +package com.seong.shoutlink.global.auth.authentication; + +import com.seong.shoutlink.domain.member.MemberRole; +import java.util.List; + +public record JwtAuthentication( + Long memberId, + MemberRole memberRole, + String accessToken) implements Authentication { + + @Override + public Long getPrincipal() { + return memberId; + } + + @Override + public List getAuthorities() { + return memberRole.getAuthorities(); + } + + @Override + public String getCredentials() { + return accessToken; + } +} diff --git a/src/main/java/com/seong/shoutlink/global/auth/authentication/JwtAuthenticationInterceptor.java b/src/main/java/com/seong/shoutlink/global/auth/authentication/JwtAuthenticationInterceptor.java new file mode 100644 index 0000000..a5e0500 --- /dev/null +++ b/src/main/java/com/seong/shoutlink/global/auth/authentication/JwtAuthenticationInterceptor.java @@ -0,0 +1,55 @@ +package com.seong.shoutlink.global.auth.authentication; + +import com.seong.shoutlink.global.exception.ErrorCode; +import com.seong.shoutlink.global.exception.ShoutLinkException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.servlet.HandlerInterceptor; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationInterceptor implements HandlerInterceptor { + + private static final String HEADER = "Authorization"; + private static final String BEARER = "Bearer "; + + private final JwtAuthenticationProvider jwtAuthenticationProvider; + private final AuthenticationContext authenticationContext; + + @Override + public boolean preHandle( + HttpServletRequest request, + HttpServletResponse response, + Object handler) { + log.debug("[Auth] JWT 인증 인터셉터 시작"); + String bearerAccessToken = request.getHeader(HEADER); + if(Objects.nonNull(bearerAccessToken)) { + log.debug("[Auth] JWT 인증 프로세스 시작"); + String accessToken = removeBearer(bearerAccessToken); + JwtAuthentication authentication = jwtAuthenticationProvider.authenticate(accessToken); + authenticationContext.setAuthentication(authentication); + log.debug("[Auth] JWT 인증 프로세스 종료. 사용자 인증됨. {}", authentication); + } + log.debug("[Auth] Jwt 인증 인터셉터 종료"); + return true; + } + + private String removeBearer(String bearerAccessToken) { + if(!bearerAccessToken.contains(BEARER)) { + throw new ShoutLinkException("올바르지 않은 액세스 토큰 형식입니다.", ErrorCode.INVALID_ACCESS_TOKEN); + } + return bearerAccessToken.replace(BEARER, ""); + } + + @Override + public void afterCompletion( + HttpServletRequest request, + HttpServletResponse response, + Object handler, + Exception ex) { + authenticationContext.releaseContext(); + } +} diff --git a/src/main/java/com/seong/shoutlink/global/auth/authentication/JwtAuthenticationProvider.java b/src/main/java/com/seong/shoutlink/global/auth/authentication/JwtAuthenticationProvider.java new file mode 100644 index 0000000..57faaa1 --- /dev/null +++ b/src/main/java/com/seong/shoutlink/global/auth/authentication/JwtAuthenticationProvider.java @@ -0,0 +1,16 @@ +package com.seong.shoutlink.global.auth.authentication; + +import com.seong.shoutlink.domain.auth.JwtProvider; +import com.seong.shoutlink.domain.auth.service.response.ClaimsResponse; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class JwtAuthenticationProvider { + + private final JwtProvider jwtProvider; + + public JwtAuthentication authenticate(String accessToken) { + ClaimsResponse claims = jwtProvider.parseAccessToken(accessToken); + return new JwtAuthentication(claims.memberId(), claims.memberRole(), accessToken); + } +} diff --git a/src/main/java/com/seong/shoutlink/global/auth/jwt/JJwtProvider.java b/src/main/java/com/seong/shoutlink/global/auth/jwt/JJwtProvider.java index e5ca6f9..febc074 100644 --- a/src/main/java/com/seong/shoutlink/global/auth/jwt/JJwtProvider.java +++ b/src/main/java/com/seong/shoutlink/global/auth/jwt/JJwtProvider.java @@ -1,15 +1,24 @@ package com.seong.shoutlink.global.auth.jwt; import com.seong.shoutlink.domain.auth.JwtProvider; +import com.seong.shoutlink.domain.auth.service.response.ClaimsResponse; import com.seong.shoutlink.domain.auth.service.response.TokenResponse; import com.seong.shoutlink.domain.member.MemberRole; +import com.seong.shoutlink.global.exception.ErrorCode; +import com.seong.shoutlink.global.exception.ShoutLinkException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import java.nio.charset.StandardCharsets; import java.util.Date; +import java.util.List; import javax.crypto.SecretKey; +import lombok.extern.slf4j.Slf4j; +@Slf4j public class JJwtProvider implements JwtProvider { private static final String ROLE = "role"; @@ -73,4 +82,22 @@ private String createRefreshToken(Long userId, MemberRole memberRole) { .signWith(refreshSecretKey) .compact(); } + + @Override + public ClaimsResponse parseAccessToken(String accessToken) { + try { + Claims claims = accessTokenParser.parseSignedClaims(accessToken).getPayload(); + Long memberId = Long.valueOf(claims.getSubject()); + MemberRole memberRole = MemberRole.valueOf(claims.get(ROLE, String.class)); + List authorities = memberRole.getAuthorities(); + return new ClaimsResponse(memberId, memberRole, authorities); + } catch (ExpiredJwtException e) { + throw new ShoutLinkException("만료된 액세스 토큰입니다.", ErrorCode.EXPIRED_ACCESS_TOKEN); + } catch (JwtException e) { + log.debug("[Ex] {} 유효하지 않은 액세스 토큰입니다.", e.getClass().getSimpleName()); + } catch (RuntimeException e) { + log.debug("[Ex] {} 유효하지 않은 인자가 전달되었습니다.", e.getClass().getSimpleName()); + } + throw new ShoutLinkException("유요하지 않은 액세스 토큰입니다.", ErrorCode.INVALID_ACCESS_TOKEN); + } } diff --git a/src/main/java/com/seong/shoutlink/global/config/AuthConfig.java b/src/main/java/com/seong/shoutlink/global/config/AuthConfig.java index 99e5764..58a4974 100644 --- a/src/main/java/com/seong/shoutlink/global/config/AuthConfig.java +++ b/src/main/java/com/seong/shoutlink/global/config/AuthConfig.java @@ -3,6 +3,8 @@ import com.seong.shoutlink.domain.auth.JwtProvider; import com.seong.shoutlink.domain.auth.PasswordEncoder; import com.seong.shoutlink.domain.auth.SimplePasswordEncoder; +import com.seong.shoutlink.global.auth.authentication.AuthenticationContext; +import com.seong.shoutlink.global.auth.authentication.JwtAuthenticationProvider; import com.seong.shoutlink.global.auth.jwt.JJwtProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -25,4 +27,14 @@ public JwtProvider jwtProvider( public PasswordEncoder passwordEncoder() { return new SimplePasswordEncoder(); } + + @Bean + public JwtAuthenticationProvider jwtAuthenticationProvider(JwtProvider jwtProvider) { + return new JwtAuthenticationProvider(jwtProvider); + } + + @Bean + public AuthenticationContext authenticationContext() { + return new AuthenticationContext(); + } } diff --git a/src/main/java/com/seong/shoutlink/global/config/WebConfig.java b/src/main/java/com/seong/shoutlink/global/config/WebConfig.java new file mode 100644 index 0000000..ef0d5de --- /dev/null +++ b/src/main/java/com/seong/shoutlink/global/config/WebConfig.java @@ -0,0 +1,25 @@ +package com.seong.shoutlink.global.config; + +import com.seong.shoutlink.global.auth.authentication.AuthenticationContext; +import com.seong.shoutlink.global.auth.authentication.JwtAuthenticationInterceptor; +import com.seong.shoutlink.global.auth.authentication.JwtAuthenticationProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final JwtAuthenticationProvider jwtAuthenticationProvider; + private final AuthenticationContext authenticationContext; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor( + new JwtAuthenticationInterceptor(jwtAuthenticationProvider, authenticationContext)) + .order(1) + .addPathPatterns("/api/**"); + } +} diff --git a/src/main/java/com/seong/shoutlink/global/exception/ErrorCode.java b/src/main/java/com/seong/shoutlink/global/exception/ErrorCode.java index 720c26d..5d0e181 100644 --- a/src/main/java/com/seong/shoutlink/global/exception/ErrorCode.java +++ b/src/main/java/com/seong/shoutlink/global/exception/ErrorCode.java @@ -5,7 +5,9 @@ @AllArgsConstructor public enum ErrorCode { ILLEGAL_ARGUMENT("SL001"), - UNAUTHENTICATED("SL401"), + UNAUTHENTICATED("SL101"), + INVALID_ACCESS_TOKEN("SL102"), + EXPIRED_ACCESS_TOKEN("SL103"), DUPLICATE_EMAIL("SL901"), DUPLICATE_NICKNAME("SL902"); diff --git a/src/test/java/com/seong/shoutlink/base/BaseControllerTest.java b/src/test/java/com/seong/shoutlink/base/BaseControllerTest.java index 23a9409..85724b0 100644 --- a/src/test/java/com/seong/shoutlink/base/BaseControllerTest.java +++ b/src/test/java/com/seong/shoutlink/base/BaseControllerTest.java @@ -3,12 +3,19 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import com.fasterxml.jackson.databind.ObjectMapper; +import com.seong.shoutlink.base.BaseControllerTest.BaseControllerConfig; +import com.seong.shoutlink.domain.auth.JwtProvider; import com.seong.shoutlink.domain.auth.service.AuthService; +import com.seong.shoutlink.fixture.AuthFixture; +import com.seong.shoutlink.global.auth.authentication.AuthenticationContext; +import com.seong.shoutlink.global.auth.authentication.JwtAuthenticationProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.restdocs.RestDocumentationContextProvider; import org.springframework.restdocs.RestDocumentationExtension; @@ -20,10 +27,29 @@ import org.springframework.web.filter.CharacterEncodingFilter; @WebMvcTest -@Import({RestDocsConfig.class}) +@Import({RestDocsConfig.class, BaseControllerConfig.class}) @ExtendWith(RestDocumentationExtension.class) public class BaseControllerTest { + @TestConfiguration + static class BaseControllerConfig { + + @Bean + public JwtProvider jwtProvider() { + return AuthFixture.jwtProvider(); + } + + @Bean + public JwtAuthenticationProvider jwtAuthenticationProvider(JwtProvider jwtProvider) { + return new JwtAuthenticationProvider(jwtProvider); + } + + @Bean + public AuthenticationContext authenticationContext() { + return new AuthenticationContext(); + } + } + protected MockMvc mockMvc; @Autowired diff --git a/src/test/java/com/seong/shoutlink/domain/auth/service/AuthServiceTest.java b/src/test/java/com/seong/shoutlink/domain/auth/service/AuthServiceTest.java index b215399..1ba8ae7 100644 --- a/src/test/java/com/seong/shoutlink/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/seong/shoutlink/domain/auth/service/AuthServiceTest.java @@ -43,7 +43,7 @@ void setUp() { String email = "stub@stub.com"; String password = "stub123!"; String nickname = "stub"; - MemberRole memberRole = MemberRole.USER; + MemberRole memberRole = MemberRole.ROLE_USER; savedMember = new Member(email, password, nickname, memberRole); memberRepository = new StubMemberRepository(savedMember); authService = new AuthService(memberRepository, passwordEncoder, jwtProvider); @@ -138,7 +138,7 @@ void setUp() { savedMemberPassword = "stub123!"; String password = passwordEncoder.encode(savedMemberPassword); String nickname = "stub"; - MemberRole memberRole = MemberRole.USER; + MemberRole memberRole = MemberRole.ROLE_USER; savedMember = new Member(1L, email, password, nickname, memberRole); memberRepository = new StubMemberRepository(savedMember); authService = new AuthService(memberRepository, passwordEncoder, jwtProvider); diff --git a/src/test/java/com/seong/shoutlink/domain/member/MemberTest.java b/src/test/java/com/seong/shoutlink/domain/member/MemberTest.java index 0aae82b..c09cd1c 100644 --- a/src/test/java/com/seong/shoutlink/domain/member/MemberTest.java +++ b/src/test/java/com/seong/shoutlink/domain/member/MemberTest.java @@ -20,7 +20,7 @@ class NewMember { private String email = "email@email.com"; private String password = "1234"; private String nickname = "nickname"; - private MemberRole memberRole = MemberRole.USER; + private MemberRole memberRole = MemberRole.ROLE_USER; @Test @DisplayName("회원이 생성된다.") diff --git a/src/test/java/com/seong/shoutlink/fixture/AuthFixture.java b/src/test/java/com/seong/shoutlink/fixture/AuthFixture.java new file mode 100644 index 0000000..3b19f9e --- /dev/null +++ b/src/test/java/com/seong/shoutlink/fixture/AuthFixture.java @@ -0,0 +1,16 @@ +package com.seong.shoutlink.fixture; + +import com.seong.shoutlink.domain.auth.JwtProvider; +import com.seong.shoutlink.global.auth.jwt.JJwtProvider; + +public final class AuthFixture { + + public static JwtProvider jwtProvider() { + String issuer = "test"; + int expirySeconds = 3600; + int refreshExpirySeconds = 18000; + String secret = "thisisjusttestaccesssecretsodontworry"; + String refreshSecret = "thisisjusttestrefreshsecretsodontworry"; + return new JJwtProvider(issuer, expirySeconds, refreshExpirySeconds, secret, refreshSecret); + } +} diff --git a/src/test/java/com/seong/shoutlink/global/auth/authentication/AuthenticationContextTest.java b/src/test/java/com/seong/shoutlink/global/auth/authentication/AuthenticationContextTest.java new file mode 100644 index 0000000..b9c5680 --- /dev/null +++ b/src/test/java/com/seong/shoutlink/global/auth/authentication/AuthenticationContextTest.java @@ -0,0 +1,61 @@ +package com.seong.shoutlink.global.auth.authentication; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.seong.shoutlink.domain.member.MemberRole; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class AuthenticationContextTest { + + AuthenticationContext authenticationContext = new AuthenticationContext(); + + @Nested + @DisplayName("setAuthentication 메서드 호출 시") + class SetAuthenticationTest { + + @Test + @DisplayName("성공: 인증 컨텍스트 사용자 인증 정보 설정됨") + void setAuthentication() { + //given + long memberId = 1L; + MemberRole memberRole = MemberRole.ROLE_USER; + String accessToken = "accessToken"; + JwtAuthentication givenAuthentication + = new JwtAuthentication(memberId, memberRole, accessToken); + + //when + authenticationContext.setAuthentication(givenAuthentication); + + //then + Authentication authentication = authenticationContext.getAuthentication(); + assertThat(authentication.getPrincipal()).isEqualTo(memberId); + assertThat(authentication.getCredentials()).isEqualTo(accessToken); + assertThat(authentication.getAuthorities()) + .containsExactlyElementsOf(memberRole.getAuthorities()); + } + } + + @Nested + @DisplayName("releaseContext 메서드 호출 시") + class ReleaseContextTest { + + @Test + @DisplayName("성공: 인증 컨텍스트 제거됨") + void releaseContext() { + //given + JwtAuthentication givenAuthentication + = new JwtAuthentication(1L, MemberRole.ROLE_USER, "accessToken"); + authenticationContext.setAuthentication(givenAuthentication); + + //when + authenticationContext.releaseContext(); + + //then + assertThat(authenticationContext) + .extracting(AuthenticationContext::getAuthentication) + .isNull(); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/seong/shoutlink/global/auth/authentication/JwtAuthenticationInterceptorTest.java b/src/test/java/com/seong/shoutlink/global/auth/authentication/JwtAuthenticationInterceptorTest.java new file mode 100644 index 0000000..f2234f4 --- /dev/null +++ b/src/test/java/com/seong/shoutlink/global/auth/authentication/JwtAuthenticationInterceptorTest.java @@ -0,0 +1,138 @@ +package com.seong.shoutlink.global.auth.authentication; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchException; + +import com.seong.shoutlink.domain.auth.JwtProvider; +import com.seong.shoutlink.domain.auth.service.response.TokenResponse; +import com.seong.shoutlink.domain.member.MemberRole; +import com.seong.shoutlink.fixture.AuthFixture; +import com.seong.shoutlink.global.exception.ErrorCode; +import com.seong.shoutlink.global.exception.ShoutLinkException; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +class JwtAuthenticationInterceptorTest { + + JwtAuthenticationInterceptor jwtAuthenticationInterceptor; + JwtAuthenticationProvider jwtAuthenticationProvider; + JwtProvider jwtProvider; + AuthenticationContext authenticationContext; + + @BeforeEach + void setUp() { + authenticationContext = new AuthenticationContext(); + jwtProvider = AuthFixture.jwtProvider(); + jwtAuthenticationProvider = new JwtAuthenticationProvider(jwtProvider); + jwtAuthenticationInterceptor = new JwtAuthenticationInterceptor(jwtAuthenticationProvider, + authenticationContext); + } + + @Nested + @DisplayName("preHandle 메서드 호출 시") + class PreHandleTest { + + HttpServletResponse httpServletResponse; + + @BeforeEach + void setUp() { + httpServletResponse = new MockHttpServletResponse(); + } + + @Test + @DisplayName("성공: Authorization 헤더 포함 시 인증 프로세스 진행") + void runAuthenticationProcess_WhenContainsAuthorizationHeader() { + //given + TokenResponse tokenResponse = jwtProvider.createToken(1L, MemberRole.ROLE_USER); + String bearerAccessToken = "Bearer " + tokenResponse.accessToken(); + MockHttpServletRequest mockHttpServletRequest = new MockHttpServletRequest(); + mockHttpServletRequest.addHeader("Authorization", bearerAccessToken); + + //when + boolean result = jwtAuthenticationInterceptor.preHandle(mockHttpServletRequest, + httpServletResponse, + null); + + //then + assertThat(result).isTrue(); + Authentication authentication = authenticationContext.getAuthentication(); + assertThat(authentication).isNotNull(); + } + + @Test + @DisplayName("성공: Authorization 헤더 미 포함 시 무시") + void ignoreAuthenticationProcess_WhenNotContainsAuthorizationHeader() { + //given + MockHttpServletRequest mockHttpServletRequest = new MockHttpServletRequest(); + + //when + boolean result = jwtAuthenticationInterceptor.preHandle(mockHttpServletRequest, + httpServletResponse, + null); + + //then + assertThat(result).isTrue(); + Authentication authentication = authenticationContext.getAuthentication(); + assertThat(authentication).isNull(); + } + + @Test + @DisplayName("예외(invalidAccessToken): 액세스 토큰 형식이 Beaerer 타입이 아닐 시") + void invalidAccessToken_WhenAccessTokenTypeIsNotBearer() { + //given + TokenResponse tokenResponse = jwtProvider.createToken(1L, MemberRole.ROLE_USER); + String bearerAccessToken = "invalid" + tokenResponse.accessToken(); + MockHttpServletRequest mockHttpServletRequest = new MockHttpServletRequest(); + mockHttpServletRequest.addHeader("Authorization", bearerAccessToken); + + //when + Exception exception = catchException( + () -> jwtAuthenticationInterceptor.preHandle(mockHttpServletRequest, + httpServletResponse, + null)); + + //then + assertThat(exception) + .isInstanceOf(ShoutLinkException.class) + .extracting(e -> ((ShoutLinkException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_ACCESS_TOKEN); + } + } + + @Nested + @DisplayName("afterCompletion 메서드 호출 시") + class AfterCompletionTest { + + HttpServletResponse httpServletResponse; + + @BeforeEach + void setUp() { + httpServletResponse = new MockHttpServletResponse(); + } + + @Test + @DisplayName("성공: 인증 컨텍스트 소멸됨") + void afterCompletion() { + //given + TokenResponse tokenResponse = jwtProvider.createToken(1L, MemberRole.ROLE_USER); + String bearerAccessToken = "Bearer " + tokenResponse.accessToken(); + MockHttpServletRequest mockHttpServletRequest = new MockHttpServletRequest(); + mockHttpServletRequest.addHeader("Authorization", bearerAccessToken); + jwtAuthenticationInterceptor.preHandle(mockHttpServletRequest, httpServletResponse, + null); + + //when + jwtAuthenticationInterceptor.afterCompletion(mockHttpServletRequest, + httpServletResponse, null, null); + + //then + Authentication authentication = authenticationContext.getAuthentication(); + assertThat(authentication).isNull(); + } + } +} diff --git a/src/test/java/com/seong/shoutlink/global/auth/authentication/JwtAuthenticationProviderTest.java b/src/test/java/com/seong/shoutlink/global/auth/authentication/JwtAuthenticationProviderTest.java new file mode 100644 index 0000000..3810a94 --- /dev/null +++ b/src/test/java/com/seong/shoutlink/global/auth/authentication/JwtAuthenticationProviderTest.java @@ -0,0 +1,52 @@ +package com.seong.shoutlink.global.auth.authentication; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.seong.shoutlink.domain.auth.JwtProvider; +import com.seong.shoutlink.domain.auth.service.response.TokenResponse; +import com.seong.shoutlink.domain.member.MemberRole; +import com.seong.shoutlink.global.auth.jwt.JJwtProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class JwtAuthenticationProviderTest { + + JwtProvider jwtProvider; + JwtAuthenticationProvider jwtAuthenticationProvider; + + @BeforeEach + void setUp() { + String issuer = "test"; + int expirySeconds = 3600; + int refreshExpirySeconds = 18000; + String secret = "thisisjusttestaccesssecretsodontworry"; + String refreshSecret = "thisisjusttestrefreshsecretsodontworry"; + jwtProvider + = new JJwtProvider(issuer, expirySeconds, refreshExpirySeconds, secret, refreshSecret); + jwtAuthenticationProvider = new JwtAuthenticationProvider(jwtProvider); + } + + @Nested + @DisplayName("성공: authenticate 메서드 호출 시") + class AuthenticatedTest { + + @Test + @DisplayName("성공: 사용자 인증됨") + void authenticate() { + //given + long memberId = 1L; + MemberRole memberRole = MemberRole.ROLE_USER; + TokenResponse tokenResponse = jwtProvider.createToken(memberId, memberRole); + + //when + JwtAuthentication authentication = jwtAuthenticationProvider.authenticate( + tokenResponse.accessToken()); + + //then + assertThat(authentication.getPrincipal()).isEqualTo(memberId); + assertThat(authentication.getAuthorities()).isEqualTo(memberRole.getAuthorities()); + } + } +} diff --git a/src/test/java/com/seong/shoutlink/global/auth/jwt/JJwtProviderTest.java b/src/test/java/com/seong/shoutlink/global/auth/jwt/JJwtProviderTest.java index d9b59e0..f84d284 100644 --- a/src/test/java/com/seong/shoutlink/global/auth/jwt/JJwtProviderTest.java +++ b/src/test/java/com/seong/shoutlink/global/auth/jwt/JJwtProviderTest.java @@ -1,20 +1,39 @@ package com.seong.shoutlink.global.auth.jwt; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchException; import com.seong.shoutlink.domain.auth.JwtProvider; +import com.seong.shoutlink.domain.auth.service.response.ClaimsResponse; import com.seong.shoutlink.domain.auth.service.response.TokenResponse; import com.seong.shoutlink.domain.member.MemberRole; +import com.seong.shoutlink.global.exception.ErrorCode; +import com.seong.shoutlink.global.exception.ShoutLinkException; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class JJwtProviderTest { - JwtProvider jwtProvider = new JJwtProvider( - "test", 3600, 18000, - "thisisjusttestaccesssecretsodontworry", - "thisisjusttestrefreshsecretsodontworry"); + String issuer; + int expirySeconds; + int refreshExpirySeconds; + String secret; + String refreshSecret; + JwtProvider jwtProvider; + + @BeforeEach + void setUp() { + issuer = "test"; + expirySeconds = 3600; + refreshExpirySeconds = 18000; + secret = "thisisjusttestaccesssecretsodontworry"; + refreshSecret = "thisisjusttestrefreshsecretsodontworry"; + jwtProvider + = new JJwtProvider(issuer, expirySeconds, refreshExpirySeconds, secret, refreshSecret); + } @Nested @DisplayName("createToken 메서드 실행 시") @@ -25,7 +44,7 @@ class CreateTokenTest { void createToken() { //given Long memberId = 1L; - MemberRole volunteerRole = MemberRole.USER; + MemberRole volunteerRole = MemberRole.ROLE_USER; //when TokenResponse TokenResponse = jwtProvider.createToken(memberId, volunteerRole); @@ -36,4 +55,76 @@ void createToken() { assertThat(TokenResponse.accessToken()).isNotEqualTo(TokenResponse.refreshToken()); } } + + @Nested + @DisplayName("parseAccessToken 메서드 실행 시") + class ParseAccessTokenTest { + + Long memberId; + MemberRole memberRole; + + @BeforeEach + void setUp() { + memberId = 1L; + memberRole = MemberRole.ROLE_USER; + } + + @Test + @DisplayName("성공: 액세스 토큰 파싱 결과 반환") + void parseAccessToken() { + //given + TokenResponse tokenResponse = jwtProvider.createToken(memberId, memberRole); + + //when + ClaimsResponse claims = jwtProvider.parseAccessToken(tokenResponse.accessToken()); + + //then + Long findMemberId = claims.memberId(); + List findAuthorities = claims.authorities(); + assertThat(findMemberId).isEqualTo(memberId); + assertThat(findAuthorities).containsExactlyElementsOf(memberRole.getAuthorities()); + } + + @Test + @DisplayName("예외(invalidAccessToken): 유효하지 않은 액세스 토큰") + void invalidAccessToken_WhenAccessTokenIsInvalid() { + //given + String notEqualSecret = secret + "asdf"; + JJwtProvider invalidJJwtProvider = new JJwtProvider(issuer, expirySeconds, + refreshExpirySeconds, notEqualSecret, refreshSecret); + TokenResponse TokenResponse = invalidJJwtProvider.createToken(memberId, memberRole); + String invalidAccessToken = TokenResponse.accessToken(); + + //when + Exception exception = catchException( + () -> jwtProvider.parseAccessToken(invalidAccessToken)); + + //then + assertThat(exception) + .isInstanceOf(ShoutLinkException.class) + .extracting(e -> ((ShoutLinkException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_ACCESS_TOKEN); + } + + @Test + @DisplayName("예외(expiredAccessToken): 만료된 토큰") + void expiredAccessToken_WhenAccessTokenIsExpired() { + //given + int alreadyExpiredSeconds = -1; + JJwtProvider expiredJJwtProvider = new JJwtProvider(issuer, alreadyExpiredSeconds, + refreshExpirySeconds, secret, refreshSecret); + TokenResponse tokenResponse = expiredJJwtProvider.createToken(memberId, memberRole); + String expiredAccessToken = tokenResponse.accessToken(); + + //when + Exception exception = catchException( + () -> jwtProvider.parseAccessToken(expiredAccessToken)); + + //then + assertThat(exception) + .isInstanceOf(ShoutLinkException.class) + .extracting(e -> ((ShoutLinkException) e).getErrorCode()) + .isEqualTo(ErrorCode.EXPIRED_ACCESS_TOKEN); + } + } } \ No newline at end of file