diff --git a/src/main/java/com/example/solidconnection/auth/domain/TokenType.java b/src/main/java/com/example/solidconnection/auth/domain/TokenType.java index 7fa6045f7..ad5607a27 100644 --- a/src/main/java/com/example/solidconnection/auth/domain/TokenType.java +++ b/src/main/java/com/example/solidconnection/auth/domain/TokenType.java @@ -7,7 +7,9 @@ public enum TokenType { ACCESS("ACCESS:", 1000 * 60 * 60), // 1hour REFRESH("REFRESH:", 1000 * 60 * 60 * 24 * 7), // 7days - KAKAO_OAUTH("KAKAO:", 1000 * 60 * 60); // 1hour + KAKAO_OAUTH("KAKAO:", 1000 * 60 * 60), // 1hour + BLACKLIST("BLACKLIST:", ACCESS.expireTime) + ; private final String prefix; private final int expireTime; diff --git a/src/main/java/com/example/solidconnection/auth/service/AuthService.java b/src/main/java/com/example/solidconnection/auth/service/AuthService.java index 29fb1b347..e16044e97 100644 --- a/src/main/java/com/example/solidconnection/auth/service/AuthService.java +++ b/src/main/java/com/example/solidconnection/auth/service/AuthService.java @@ -15,6 +15,7 @@ import java.util.concurrent.TimeUnit; import static com.example.solidconnection.auth.domain.TokenType.ACCESS; +import static com.example.solidconnection.auth.domain.TokenType.BLACKLIST; import static com.example.solidconnection.auth.domain.TokenType.REFRESH; import static com.example.solidconnection.custom.exception.ErrorCode.REFRESH_TOKEN_EXPIRED; @@ -30,16 +31,13 @@ public class AuthService { /* * 로그아웃 한다. - * - 리프레시 토큰을 무효화하기 위해 리프레시 토큰의 value 를 변경한다. - * - 어떤 사용자가 엑세스 토큰으로 인증이 필요한 기능을 사용하려 할 때, 로그아웃 검증이 진행되는데, - * - 이때 리프레시 토큰의 value 가 SIGN_OUT_VALUE 이면 예외 응답이 반환된다. - * - (TokenValidator.validateNotSignOut() 참고) + * - 엑세스 토큰을 블랙리스트에 추가한다. * */ - public void signOut(String email) { + public void signOut(String accessToken) { redisTemplate.opsForValue().set( - REFRESH.addPrefixToSubject(email), - SIGN_OUT_VALUE, - REFRESH.getExpireTime(), + BLACKLIST.addPrefixToSubject(accessToken), + accessToken, + BLACKLIST.getExpireTime(), TimeUnit.MILLISECONDS ); } @@ -61,15 +59,15 @@ public void quit(String email) { * - 리프레시 토큰이 만료되었거나, 존재하지 않는다면 예외 응답을 반환한다. * - 리프레시 토큰이 존재한다면, 액세스 토큰을 재발급한다. * */ - public ReissueResponse reissue(String email) { + public ReissueResponse reissue(String subject) { // 리프레시 토큰 만료 확인 - String refreshTokenKey = REFRESH.addPrefixToSubject(email); + String refreshTokenKey = REFRESH.addPrefixToSubject(subject); String refreshToken = redisTemplate.opsForValue().get(refreshTokenKey); if (ObjectUtils.isEmpty(refreshToken)) { throw new CustomException(REFRESH_TOKEN_EXPIRED); } // 액세스 토큰 재발급 - String newAccessToken = tokenProvider.generateToken(email, ACCESS); + String newAccessToken = tokenProvider.generateToken(subject, ACCESS); tokenProvider.saveToken(newAccessToken, ACCESS); return new ReissueResponse(newAccessToken); } diff --git a/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java index 693a968ea..9cba77c36 100644 --- a/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java +++ b/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java @@ -12,8 +12,8 @@ import java.util.Date; import java.util.concurrent.TimeUnit; +import static com.example.solidconnection.util.JwtUtils.parseSubjectIgnoringExpiration; import static com.example.solidconnection.util.JwtUtils.parseSubject; -import static com.example.solidconnection.util.JwtUtils.parseSubjectOrElseThrow; @RequiredArgsConstructor @Component @@ -35,7 +35,7 @@ public String generateToken(String email, TokenType tokenType) { } public String saveToken(String token, TokenType tokenType) { - String subject = parseSubjectOrElseThrow(token, jwtProperties.secret()); + String subject = parseSubject(token, jwtProperties.secret()); redisTemplate.opsForValue().set( tokenType.addPrefixToSubject(subject), token, @@ -46,6 +46,6 @@ public String saveToken(String token, TokenType tokenType) { } public String getEmail(String token) { - return parseSubject(token, jwtProperties.secret()); + return parseSubjectIgnoringExpiration(token, jwtProperties.secret()); } } diff --git a/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationEntryPoint.java b/src/main/java/com/example/solidconnection/config/security/ExceptionHandlerFilter.java similarity index 63% rename from src/main/java/com/example/solidconnection/config/security/JwtAuthenticationEntryPoint.java rename to src/main/java/com/example/solidconnection/config/security/ExceptionHandlerFilter.java index 7487858be..59022c198 100644 --- a/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationEntryPoint.java +++ b/src/main/java/com/example/solidconnection/config/security/ExceptionHandlerFilter.java @@ -3,12 +3,15 @@ import com.example.solidconnection.custom.exception.CustomException; import com.example.solidconnection.custom.response.ErrorResponse; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; import lombok.RequiredArgsConstructor; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @@ -16,24 +19,32 @@ @Component @RequiredArgsConstructor -public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { +public class ExceptionHandlerFilter extends OncePerRequestFilter { private final ObjectMapper objectMapper; @Override - public void commence(HttpServletRequest request, HttpServletResponse response, - AuthenticationException authException) throws IOException { - ErrorResponse errorResponse = new ErrorResponse(AUTHENTICATION_FAILED, authException.getMessage()); - writeResponse(response, errorResponse); + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (CustomException e) { + customCommence(response, e); + } catch (Exception e) { + generalCommence(response, e); + } } - public void generalCommence(HttpServletResponse response, Exception exception) throws IOException { - ErrorResponse errorResponse = new ErrorResponse(AUTHENTICATION_FAILED, exception.getMessage()); + public void customCommence(HttpServletResponse response, CustomException customException) throws IOException { + SecurityContextHolder.clearContext(); + ErrorResponse errorResponse = new ErrorResponse(customException); writeResponse(response, errorResponse); } - public void customCommence(HttpServletResponse response, CustomException customException) throws IOException { - ErrorResponse errorResponse = new ErrorResponse(customException); + public void generalCommence(HttpServletResponse response, Exception exception) throws IOException { + SecurityContextHolder.clearContext(); + ErrorResponse errorResponse = new ErrorResponse(AUTHENTICATION_FAILED, exception.getMessage()); writeResponse(response, errorResponse); } diff --git a/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationFilter.java b/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationFilter.java index e01009be1..5c7ab9f97 100644 --- a/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationFilter.java @@ -1,6 +1,5 @@ package com.example.solidconnection.config.security; -import com.example.solidconnection.custom.exception.CustomException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -8,7 +7,6 @@ import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; -import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; @@ -16,7 +14,7 @@ import java.io.IOException; -import static com.example.solidconnection.util.JwtUtils.parseSubjectOrElseThrow; +import static com.example.solidconnection.util.JwtUtils.parseSubject; import static com.example.solidconnection.util.JwtUtils.parseTokenFromRequest; @Component @@ -27,7 +25,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final String REISSUE_METHOD = "post"; private final JwtProperties jwtProperties; - private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @@ -39,19 +36,11 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, return; } - try { - String subject = parseSubjectOrElseThrow(token, jwtProperties.secret()); - UserDetails userDetails = new JwtUserDetails(subject); - Authentication auth = new JwtAuthentication(userDetails, token, userDetails.getAuthorities()); - SecurityContextHolder.getContext().setAuthentication(auth); - filterChain.doFilter(request, response); - } catch (AuthenticationException e) { - jwtAuthenticationEntryPoint.commence(request, response, e); - } catch (CustomException e) { - jwtAuthenticationEntryPoint.customCommence(response, e); - } catch (Exception e) { - jwtAuthenticationEntryPoint.generalCommence(response, e); - } + String subject = parseSubject(token, jwtProperties.secret()); + UserDetails userDetails = new JwtUserDetails(subject); + Authentication auth = new JwtAuthentication(userDetails, token, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(auth); + filterChain.doFilter(request, response); } private boolean isReissueRequest(HttpServletRequest request) { diff --git a/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java b/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java index d28d883ca..3f6307f8f 100644 --- a/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java +++ b/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java @@ -19,6 +19,7 @@ public class SecurityConfiguration { private final CorsProperties corsProperties; + private final ExceptionHandlerFilter exceptionHandlerFilter; private final SignOutCheckFilter signOutCheckFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter; @@ -45,8 +46,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) - .addFilterBefore(this.jwtAuthenticationFilter, BasicAuthenticationFilter.class) - .addFilterBefore(this.signOutCheckFilter, JwtAuthenticationFilter.class) + .addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter.class) + .addFilterBefore(signOutCheckFilter, JwtAuthenticationFilter.class) + .addFilterBefore(exceptionHandlerFilter, SignOutCheckFilter.class) .build(); } } diff --git a/src/main/java/com/example/solidconnection/config/security/SignOutCheckFilter.java b/src/main/java/com/example/solidconnection/config/security/SignOutCheckFilter.java index 3c1218d13..c71252f1f 100644 --- a/src/main/java/com/example/solidconnection/config/security/SignOutCheckFilter.java +++ b/src/main/java/com/example/solidconnection/config/security/SignOutCheckFilter.java @@ -13,10 +13,8 @@ import java.io.IOException; -import static com.example.solidconnection.auth.domain.TokenType.REFRESH; -import static com.example.solidconnection.auth.service.AuthService.SIGN_OUT_VALUE; +import static com.example.solidconnection.auth.domain.TokenType.BLACKLIST; import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_SIGN_OUT; -import static com.example.solidconnection.util.JwtUtils.parseSubject; import static com.example.solidconnection.util.JwtUtils.parseTokenFromRequest; @Component @@ -25,24 +23,20 @@ public class SignOutCheckFilter extends OncePerRequestFilter { private final RedisTemplate redisTemplate; private final JwtProperties jwtProperties; - private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { String token = parseTokenFromRequest(request); - if (token == null || !isSignOut(token)) { - filterChain.doFilter(request, response); - return; + if (token != null && hasSignedOut(token)) { + throw new CustomException(USER_ALREADY_SIGN_OUT); } - - jwtAuthenticationEntryPoint.customCommence(response, new CustomException(USER_ALREADY_SIGN_OUT)); + filterChain.doFilter(request, response); } - private boolean isSignOut(String accessToken) { - String subject = parseSubject(accessToken, jwtProperties.secret()); - String refreshToken = REFRESH.addPrefixToSubject(subject); - return SIGN_OUT_VALUE.equals(redisTemplate.opsForValue().get(refreshToken)); + private boolean hasSignedOut(String accessToken) { + String blacklistKey = BLACKLIST.addPrefixToSubject(accessToken); + return redisTemplate.opsForValue().get(blacklistKey) != null; } } diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/AuthType.java b/src/main/java/com/example/solidconnection/siteuser/domain/AuthType.java new file mode 100644 index 000000000..d9d0b582c --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/domain/AuthType.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.siteuser.domain; + +public enum AuthType { + + KAKAO, + APPLE, + EMAIL, + ; +} diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java index e518a5efb..2c2a5d8be 100644 --- a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java @@ -17,6 +17,8 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; @@ -32,15 +34,25 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @AllArgsConstructor +@Table(uniqueConstraints = { + @UniqueConstraint( + name = "uk_site_user_email_auth_type", + columnNames = {"email", "auth_type"} + ) +}) public class SiteUser { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false, length = 100) + @Column(name = "email", nullable = false, length = 100) private String email; + @Column(name = "auth_type", nullable = false, length = 100) + @Enumerated(EnumType.STRING) + private AuthType authType; + @Setter @Column(nullable = false, length = 100) private String nickname; @@ -100,5 +112,25 @@ public SiteUser( this.preparationStage = preparationStage; this.role = role; this.gender = gender; + this.authType = AuthType.KAKAO; + } + + public SiteUser( + String email, + String nickname, + String profileImageUrl, + String birth, + PreparationStatus preparationStage, + Role role, + Gender gender, + AuthType authType) { + this.email = email; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.birth = birth; + this.preparationStage = preparationStage; + this.role = role; + this.gender = gender; + this.authType = authType; } } diff --git a/src/main/java/com/example/solidconnection/util/JwtUtils.java b/src/main/java/com/example/solidconnection/util/JwtUtils.java index a3775365d..3a1b58520 100644 --- a/src/main/java/com/example/solidconnection/util/JwtUtils.java +++ b/src/main/java/com/example/solidconnection/util/JwtUtils.java @@ -6,6 +6,8 @@ import jakarta.servlet.http.HttpServletRequest; import org.springframework.stereotype.Component; +import java.util.Date; + import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; @Component @@ -25,22 +27,37 @@ public static String parseTokenFromRequest(HttpServletRequest request) { return token.substring(TOKEN_PREFIX.length()); } - public static String parseSubject(String token, String secretKey) { + public static String parseSubjectIgnoringExpiration(String token, String secretKey) { try { return extractSubject(token, secretKey); } catch (ExpiredJwtException e) { return e.getClaims().getSubject(); + } catch (Exception e) { + throw new CustomException(INVALID_TOKEN); } } - public static String parseSubjectOrElseThrow(String token, String secretKey) { + public static String parseSubject(String token, String secretKey) { try { return extractSubject(token, secretKey); - } catch (ExpiredJwtException e) { + } catch (Exception e) { throw new CustomException(INVALID_TOKEN); } } + public static boolean isExpired(String token, String secretKey) { + try { + Date expiration = Jwts.parser() + .setSigningKey(secretKey) + .parseClaimsJws(token) + .getBody() + .getExpiration(); + return expiration.before(new Date()); + } catch (Exception e) { + return true; + } + } + private static String extractSubject(String token, String secretKey) throws ExpiredJwtException { return Jwts.parser() .setSigningKey(secretKey) diff --git a/src/main/resources/db/migration/V3__add_auth_type_column_and_unique_key.sql b/src/main/resources/db/migration/V3__add_auth_type_column_and_unique_key.sql new file mode 100644 index 000000000..e89c4aa1b --- /dev/null +++ b/src/main/resources/db/migration/V3__add_auth_type_column_and_unique_key.sql @@ -0,0 +1,13 @@ +ALTER TABLE site_user +ADD COLUMN auth_type ENUM('KAKAO', 'APPLE', 'EMAIL'); + +UPDATE site_user +SET auth_type = 'KAKAO' +WHERE auth_type IS NULL; + +ALTER TABLE site_user +MODIFY COLUMN auth_type ENUM('KAKAO', 'APPLE', 'EMAIL') NOT NULL; + +ALTER TABLE site_user +ADD CONSTRAINT uk_site_user_email_auth_type +UNIQUE (email, auth_type); diff --git a/src/test/java/com/example/solidconnection/config/token/TokenProviderTest.java b/src/test/java/com/example/solidconnection/auth/service/TokenProviderTest.java similarity index 96% rename from src/test/java/com/example/solidconnection/config/token/TokenProviderTest.java rename to src/test/java/com/example/solidconnection/auth/service/TokenProviderTest.java index d3992a33a..8cc91e2c0 100644 --- a/src/test/java/com/example/solidconnection/config/token/TokenProviderTest.java +++ b/src/test/java/com/example/solidconnection/auth/service/TokenProviderTest.java @@ -1,7 +1,6 @@ -package com.example.solidconnection.config.token; +package com.example.solidconnection.auth.service; import com.example.solidconnection.auth.domain.TokenType; -import com.example.solidconnection.auth.service.TokenProvider; import com.example.solidconnection.config.security.JwtProperties; import com.example.solidconnection.custom.exception.CustomException; import com.example.solidconnection.custom.exception.ErrorCode; diff --git a/src/test/java/com/example/solidconnection/config/security/ExceptionHandlerFilterTest.java b/src/test/java/com/example/solidconnection/config/security/ExceptionHandlerFilterTest.java new file mode 100644 index 000000000..f4e8dc666 --- /dev/null +++ b/src/test/java/com/example/solidconnection/config/security/ExceptionHandlerFilterTest.java @@ -0,0 +1,91 @@ +package com.example.solidconnection.config.security; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +@TestContainerSpringBootTest +class ExceptionHandlerFilterTest { + + @Autowired + private ExceptionHandlerFilter exceptionHandlerFilter; + + private HttpServletRequest request; + private HttpServletResponse response; + private FilterChain filterChain; + + @BeforeEach() + void setUp() { + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + filterChain = spy(FilterChain.class); + } + + @Test + void 필터_체인에서_예외가_발생하면_SecurityContext_를_초기화한다() throws Exception { + // given + Authentication authentication = mock(TestingAuthenticationToken.class); + SecurityContextHolder.getContext().setAuthentication(authentication); + willThrow(new RuntimeException()).given(filterChain).doFilter(request, response); + + // when + exceptionHandlerFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void 필터_체인에서_예외가_발생하지_않으면_다음_필터로_진행한다() throws Exception { + // given + willDoNothing().given(filterChain).doFilter(request, response); + + // when + exceptionHandlerFilter.doFilterInternal(request, response, filterChain); + + // then + then(filterChain).should().doFilter(request, response); + } + + @ParameterizedTest + @MethodSource("provideException") + void 필터_체인에서_예외가_발생하면_예외_응답을_반환한다(Throwable throwable) throws Exception { + // given + willThrow(throwable).given(filterChain).doFilter(request, response); + + // when + exceptionHandlerFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); + } + + private static Stream provideException() { + return Stream.of( + new RuntimeException(), + new CustomException(ErrorCode.INVALID_TOKEN) + ); + } +} diff --git a/src/test/java/com/example/solidconnection/config/security/JwtAuthenticationFilterTest.java b/src/test/java/com/example/solidconnection/config/security/JwtAuthenticationFilterTest.java index c0256f75a..16e3639f1 100644 --- a/src/test/java/com/example/solidconnection/config/security/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/example/solidconnection/config/security/JwtAuthenticationFilterTest.java @@ -1,5 +1,6 @@ package com.example.solidconnection.config.security; +import com.example.solidconnection.custom.exception.CustomException; import com.example.solidconnection.support.TestContainerSpringBootTest; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; @@ -17,7 +18,9 @@ import java.util.Date; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.spy; @@ -89,12 +92,10 @@ class 유효하지_않은_토큰으로_인증하면_예외를_응답한다 { .compact(); request = createRequestWithToken(token); - // when - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - - // then - assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); - assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); + // when & then + assertThatCode(() -> jwtAuthenticationFilter.doFilterInternal(request, response, filterChain)) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_TOKEN.getMessage()); then(filterChain).shouldHaveNoMoreInteractions(); } @@ -109,12 +110,10 @@ class 유효하지_않은_토큰으로_인증하면_예외를_응답한다 { .compact(); request = createRequestWithToken(token); - // when - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - - // then - assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); - assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); + // when & then + assertThatCode(() -> jwtAuthenticationFilter.doFilterInternal(request, response, filterChain)) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_TOKEN.getMessage()); then(filterChain).shouldHaveNoMoreInteractions(); } } diff --git a/src/test/java/com/example/solidconnection/config/security/SignOutCheckFilterTest.java b/src/test/java/com/example/solidconnection/config/security/SignOutCheckFilterTest.java index 13544152b..a067bf9d9 100644 --- a/src/test/java/com/example/solidconnection/config/security/SignOutCheckFilterTest.java +++ b/src/test/java/com/example/solidconnection/config/security/SignOutCheckFilterTest.java @@ -1,5 +1,6 @@ package com.example.solidconnection.config.security; +import com.example.solidconnection.custom.exception.CustomException; import com.example.solidconnection.support.TestContainerSpringBootTest; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; @@ -17,9 +18,9 @@ import java.util.Date; import java.util.Objects; -import static com.example.solidconnection.auth.domain.TokenType.REFRESH; +import static com.example.solidconnection.auth.domain.TokenType.BLACKLIST; import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_SIGN_OUT; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.spy; @@ -55,15 +56,15 @@ void setUp() { @Test void 로그아웃한_토큰이면_예외를_응답한다() throws Exception { // given - request = createTokenRequest(subject); - String refreshTokenKey = REFRESH.addPrefixToSubject(subject); + String token = createToken(subject); + request = createRequest(token); + String refreshTokenKey = BLACKLIST.addPrefixToSubject(token); redisTemplate.opsForValue().set(refreshTokenKey, "signOut"); - // when - signOutCheckFilter.doFilterInternal(request, response, filterChain); - - // then - assertThat(response.getStatus()).isEqualTo(USER_ALREADY_SIGN_OUT.getCode()); + // when & then + assertThatCode(() -> signOutCheckFilter.doFilterInternal(request, response, filterChain)) + .isInstanceOf(CustomException.class) + .hasMessage(USER_ALREADY_SIGN_OUT.getMessage()); then(filterChain).shouldHaveNoMoreInteractions(); } @@ -82,7 +83,8 @@ void setUp() { @Test void 로그아웃하지_않은_토큰이면_다음_필터로_전달한다() throws Exception { // given - request = createTokenRequest(subject); + String token = createToken(subject); + request = createRequest(token); // when signOutCheckFilter.doFilterInternal(request, response, filterChain); @@ -91,14 +93,16 @@ void setUp() { then(filterChain).should().doFilter(request, response); } - private HttpServletRequest createTokenRequest(String subject) { - String token = Jwts.builder() - .setSubject(subject) - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() + 1000)) - .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) - .compact(); + private String createToken(String subject) { + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + private HttpServletRequest createRequest(String token) { MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader("Authorization", "Bearer " + token); return request; diff --git a/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java b/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java new file mode 100644 index 000000000..d3433937a --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java @@ -0,0 +1,62 @@ +package com.example.solidconnection.siteuser.repository; + +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.support.TestContainerDataJpaTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; + +import static org.assertj.core.api.Assertions.assertThatCode; + +@TestContainerDataJpaTest +class SiteUserRepositoryTest { + + @Autowired + private SiteUserRepository siteUserRepository; + + @Nested + class 이메일과_인증_유형이_동일한_사용자는_저장할_수_없다 { + + @Test + void 이메일과_인증_유형이_동일한_사용자를_저장하면_예외_응답을_반환한다() { + // given + SiteUser user1 = createSiteUser("email", AuthType.KAKAO); + SiteUser user2 = createSiteUser("email", AuthType.KAKAO); + siteUserRepository.save(user1); + + // when, then + assertThatCode(() -> siteUserRepository.save(user2)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void 이메일이_같더라도_인증_유형이_다른_사용자는_정상_저장한다() { + // given + SiteUser user1 = createSiteUser("email", AuthType.KAKAO); + SiteUser user2 = createSiteUser("email", AuthType.APPLE); + siteUserRepository.save(user1); + + // when, then + assertThatCode(() -> siteUserRepository.save(user2)) + .doesNotThrowAnyException(); + } + } + + private SiteUser createSiteUser(String email, AuthType authType) { + return new SiteUser( + email, + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE, + authType + ); + } +} diff --git a/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java b/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java index bcb110c6b..fe9b74f60 100644 --- a/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java +++ b/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java @@ -1,5 +1,6 @@ package com.example.solidconnection.support; +import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; @@ -11,6 +12,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +@ExtendWith({DatabaseClearExtension.class}) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ActiveProfiles("test") diff --git a/src/test/java/com/example/solidconnection/unit/repository/PostRepositoryTest.java b/src/test/java/com/example/solidconnection/unit/repository/PostRepositoryTest.java index 42da9de22..a37a0e6bf 100644 --- a/src/test/java/com/example/solidconnection/unit/repository/PostRepositoryTest.java +++ b/src/test/java/com/example/solidconnection/unit/repository/PostRepositoryTest.java @@ -17,7 +17,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.List; @@ -30,10 +29,13 @@ @TestContainerDataJpaTest @DisplayName("게시글 레포지토리 테스트") class PostRepositoryTest { + @Autowired private PostRepository postRepository; + @Autowired private BoardRepository boardRepository; + @Autowired private SiteUserRepository siteUserRepository; @@ -89,7 +91,6 @@ private Post createPostWithImages(Board board, SiteUser siteUser) { } @Test - @Transactional void 게시글을_조회할_때_게시글_이미지는_즉시_로딩한다() { Post foundPost = postRepository.getByIdUsingEntityGraph(post.getId()); foundPost.getPostImageList().size(); // 추가쿼리 발생하지 않는다. @@ -98,7 +99,6 @@ private Post createPostWithImages(Board board, SiteUser siteUser) { } @Test - @Transactional void 게시글을_조회할_때_게시글_이미지는_즉시_로딩한다_유효한_게시글이_아니라면_예외_응답을_반환한다() { // given Long invalidId = -1L; @@ -114,7 +114,6 @@ private Post createPostWithImages(Board board, SiteUser siteUser) { } @Test - @Transactional void 게시글을_조회한다() { Post foundPost = postRepository.getById(post.getId()); @@ -122,7 +121,6 @@ private Post createPostWithImages(Board board, SiteUser siteUser) { } @Test - @Transactional void 게시글을_조회할_때_유효한_게시글이_아니라면_예외_응답을_반환한다() { Long invalidId = -1L; @@ -136,7 +134,6 @@ private Post createPostWithImages(Board board, SiteUser siteUser) { } @Test - @Transactional void 게시글_좋아요를_등록한다() { // given Long likeCount = post.getLikeCount(); @@ -150,7 +147,6 @@ private Post createPostWithImages(Board board, SiteUser siteUser) { } @Test - @Transactional void 게시글_좋아요를_삭제한다() { // given Long likeCount = post.getLikeCount(); diff --git a/src/test/java/com/example/solidconnection/util/JwtUtilsTest.java b/src/test/java/com/example/solidconnection/util/JwtUtilsTest.java index 4dfc11540..95bdd5a52 100644 --- a/src/test/java/com/example/solidconnection/util/JwtUtilsTest.java +++ b/src/test/java/com/example/solidconnection/util/JwtUtilsTest.java @@ -12,7 +12,7 @@ import java.util.Date; import static com.example.solidconnection.util.JwtUtils.parseSubject; -import static com.example.solidconnection.util.JwtUtils.parseSubjectOrElseThrow; +import static com.example.solidconnection.util.JwtUtils.parseSubjectIgnoringExpiration; import static com.example.solidconnection.util.JwtUtils.parseTokenFromRequest; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; @@ -59,7 +59,7 @@ class 요청으로부터_토큰을_추출한다 { } @Nested - class 토큰으로부터_subject_를_추출한다 { + class 유효한_토큰으로부터_subject_를_추출한다 { @Test void 유효한_토큰의_subject_를_추출한다() { @@ -75,13 +75,29 @@ class 토큰으로부터_subject_를_추출한다 { } @Test - void 유효하지_않은_토큰의_subject_를_추출한다() { + void 유효하지_않은_토큰의_subject_를_추출하면_예외_응답을_반환한다() { + // given + String subject = "subject123"; + String token = createExpiredToken(subject); + + // when + assertThatCode(() -> parseSubject(token, jwtSecretKey)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); + } + } + + @Nested + class 만료된_토큰으로부터_subject_를_추출한다 { + + @Test + void 만료된_토큰의_subject_를_예외를_발생시키지_않고_추출한다() { // given String subject = "subject999"; - String token = createInvalidToken(subject); + String token = createExpiredToken(subject); // when - String extractedSubject = parseSubject(token, jwtSecretKey); + String extractedSubject = parseSubjectIgnoringExpiration(token, jwtSecretKey); // then assertThat(extractedSubject).isEqualTo(subject); @@ -90,16 +106,56 @@ class 토큰으로부터_subject_를_추출한다 { @Test void 유효하지_않은_토큰의_subject_를_추출하면_예외_응답을_반환한다() { // given - String subject = "subject123"; - String token = createInvalidToken(subject); + String token = createExpiredUnsignedToken("hackers secret key"); - // when - assertThatCode(() -> parseSubjectOrElseThrow(token, jwtSecretKey)) + // when & then + assertThatCode(() -> parseSubjectIgnoringExpiration(token, jwtSecretKey)) .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); } } + + @Nested + class 토큰이_만료되었는지_확인한다 { + + @Test + void 서명된_토큰의_만료_여부를_반환한다() { + // given + String subject = "subject123"; + String validToken = createValidToken(subject); + String expiredToken = createExpiredToken(subject); + + // when + boolean isExpired1 = JwtUtils.isExpired(validToken, jwtSecretKey); + boolean isExpired2 = JwtUtils.isExpired(expiredToken, jwtSecretKey); + + // then + assertAll( + () -> assertThat(isExpired1).isFalse(), + () -> assertThat(isExpired2).isTrue() + ); + } + + @Test + void 서명되지_않은_토큰의_만료_여부를_반환한다() { + // given + String subject = "subject123"; + String validToken = createValidToken(subject); + String expiredToken = createExpiredToken(subject); + + // when + boolean isExpired1 = JwtUtils.isExpired(validToken, "wrong-secret-key"); + boolean isExpired2 = JwtUtils.isExpired(expiredToken, "wrong-secret-key"); + + // then + assertAll( + () -> assertThat(isExpired1).isTrue(), + () -> assertThat(isExpired2).isTrue() + ); + } + } + private String createValidToken(String subject) { return Jwts.builder() .setSubject(subject) @@ -109,7 +165,7 @@ private String createValidToken(String subject) { .compact(); } - private String createInvalidToken(String subject) { + private String createExpiredToken(String subject) { return Jwts.builder() .setSubject(subject) .setIssuedAt(new Date()) @@ -117,4 +173,13 @@ private String createInvalidToken(String subject) { .signWith(SignatureAlgorithm.HS256, jwtSecretKey) .compact(); } + + private String createExpiredUnsignedToken(String jwtSecretKey) { + return Jwts.builder() + .setSubject("subject") + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(SignatureAlgorithm.HS256, jwtSecretKey) + .compact(); + } }