From c916d90b53777aa1d63d788bd90ee0a9356c5dad Mon Sep 17 00:00:00 2001 From: LeeJuYoung12 Date: Wed, 7 Oct 2026 19:27:03 +0900 Subject: [PATCH] =?UTF-8?q?fix=20:=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20?= =?UTF-8?q?=EC=98=81=EC=83=81=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20S3=20=EC=9E=84=EC=9D=98=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=20=EC=A0=91=EA=B7=BC,=20=EC=A0=95=EC=A7=80/=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EA=B3=84=EC=A0=95=EC=9D=B4=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B3=84=EC=86=8D=20=EC=9D=B4=EC=9A=A9=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/auth/AuthService.java | 6 ++ .../post/service/PostThumbnailService.java | 67 +++++++++++++- .../service/CustomUserDetailsService.java | 21 ++++- .../auth/service/auth/AuthServiceTest.java | 37 ++++++++ .../service/PostThumbnailServiceTest.java | 75 +++++++++++++++ .../service/CustomUserDetailsServiceTest.java | 92 +++++++++++++++++++ 6 files changed, 293 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/umc/bscene/domain/post/service/PostThumbnailServiceTest.java create mode 100644 src/test/java/com/umc/bscene/global/security/service/CustomUserDetailsServiceTest.java diff --git a/src/main/java/com/umc/bscene/domain/auth/service/auth/AuthService.java b/src/main/java/com/umc/bscene/domain/auth/service/auth/AuthService.java index 23e5f4d2..8d9932e0 100644 --- a/src/main/java/com/umc/bscene/domain/auth/service/auth/AuthService.java +++ b/src/main/java/com/umc/bscene/domain/auth/service/auth/AuthService.java @@ -301,6 +301,12 @@ public ReissueResponse reissue(ReissueRequest request) { User user = userRepository.findById(Long.parseLong(userId)) .orElseThrow(() -> new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN)); + // 정지/휴면/탈퇴 계정은 로그인과 동일하게 재발급도 차단하고, 보유한 리프레시 토큰은 폐기한다 + if (user.getStatus() != UserStatus.ACTIVE) { + stringRedisTemplate.delete("refreshToken:" + refreshTokenHash); + validateUserStatus(user); + } + AuthMember authMember = new AuthMember(user); String newAccessToken = jwtUtil.createAccessToken(authMember); String newRefreshToken = jwtUtil.createRefreshToken(authMember); diff --git a/src/main/java/com/umc/bscene/domain/post/service/PostThumbnailService.java b/src/main/java/com/umc/bscene/domain/post/service/PostThumbnailService.java index eb9b58cb..957bffc6 100644 --- a/src/main/java/com/umc/bscene/domain/post/service/PostThumbnailService.java +++ b/src/main/java/com/umc/bscene/domain/post/service/PostThumbnailService.java @@ -12,12 +12,14 @@ import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import java.io.IOException; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Locale; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -28,6 +30,10 @@ public class PostThumbnailService { private static final long FFMPEG_TIMEOUT_SECONDS = 30; + private static final long MAX_VIDEO_BYTES = 500L * 1024 * 1024; + + // 게시물 미디어는 프리사인드 URL 발급 시 MediaCategory.POST 접두사로만 올라오므로, 그 아래 객체만 처리한다 + private static final String POST_MEDIA_PREFIX = MediaCategory.POST.name().toLowerCase() + "/"; private final S3Client s3Client; private final PostService postService; @@ -44,7 +50,9 @@ public void handle(PostVideoThumbnailRequestedEvent event) { Path videoFile = null; Path thumbnailFile = null; try { - videoFile = downloadVideo(event.videoUrl()); + String key = validatedS3Key(event.videoUrl()); + validateVideoSize(key); + videoFile = downloadVideo(key); thumbnailFile = extractFirstFrame(videoFile); String thumbnailUrl = uploadThumbnail(thumbnailFile); postService.applyGeneratedThumbnail(event.postId(), thumbnailUrl); @@ -56,9 +64,60 @@ public void handle(PostVideoThumbnailRequestedEvent event) { } } - private Path downloadVideo(String videoUrl) throws IOException { - String key = URI.create(videoUrl).getPath().substring(1); - String extension = key.contains(".") ? key.substring(key.lastIndexOf('.')) : ".tmp"; + /* + * 요청 본문의 videoUrl은 사용자 입력이다. URL path를 그대로 S3 key로 쓰면 버킷 안의 + * 임의 객체(다른 유저의 녹화본·프로필 등)를 받아 썸네일로 공개할 수 있으므로, + * 우리 버킷 호스트 + 게시물 미디어 접두사 + 영상 확장자인 경우에만 처리한다. + */ + private String validatedS3Key(String videoUrl) { + URI uri; + try { + uri = URI.create(videoUrl); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("영상 URL 형식이 올바르지 않습니다.", e); + } + + String expectedHost = bucket + ".s3." + region + ".amazonaws.com"; + if (!"https".equalsIgnoreCase(uri.getScheme()) + || uri.getHost() == null + || !uri.getHost().equalsIgnoreCase(expectedHost) + || uri.getRawQuery() != null + || uri.getRawFragment() != null) { + throw new IllegalArgumentException("우리 S3에 업로드된 영상만 처리할 수 있습니다."); + } + + String path = uri.getPath(); + if (path == null || path.length() <= 1) { + throw new IllegalArgumentException("S3 객체 경로가 없습니다."); + } + + String key = path.substring(1); + if (!key.startsWith(POST_MEDIA_PREFIX) || key.contains("/../") || !isVideoKey(key)) { + throw new IllegalArgumentException("게시물 영상 경로가 아닙니다."); + } + return key; + } + + private boolean isVideoKey(String key) { + String lower = key.toLowerCase(Locale.ROOT); + return lower.endsWith(".mp4") || lower.endsWith(".mov") + || lower.endsWith(".webm") || lower.endsWith(".m4v") + || lower.endsWith(".avi"); + } + + // 과대 파일을 내려받아 디스크·ffmpeg 시간을 소모하지 않도록 다운로드 전에 크기를 확인한다 + private void validateVideoSize(String key) { + long size = s3Client.headObject(HeadObjectRequest.builder() + .bucket(bucket) + .key(key) + .build()).contentLength(); + if (size <= 0 || size > MAX_VIDEO_BYTES) { + throw new IllegalArgumentException("영상 파일 크기가 허용 범위를 벗어났습니다."); + } + } + + private Path downloadVideo(String key) throws IOException { + String extension = key.substring(key.lastIndexOf('.')); Path videoFile = Files.createTempFile("post-video-", extension); Files.delete(videoFile); diff --git a/src/main/java/com/umc/bscene/global/security/service/CustomUserDetailsService.java b/src/main/java/com/umc/bscene/global/security/service/CustomUserDetailsService.java index 33829ed2..b93e885b 100644 --- a/src/main/java/com/umc/bscene/global/security/service/CustomUserDetailsService.java +++ b/src/main/java/com/umc/bscene/global/security/service/CustomUserDetailsService.java @@ -1,9 +1,12 @@ package com.umc.bscene.global.security.service; import com.umc.bscene.domain.user.entity.User; +import com.umc.bscene.domain.user.enums.UserStatus; import com.umc.bscene.domain.user.repository.UserRepository; import com.umc.bscene.global.security.entity.AuthMember; import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -18,6 +21,10 @@ public class CustomUserDetailsService implements UserDetailsService { /** * JWT subject(userId)로 유저를 조회해 UserDetails로 변환 * JwtAuthFilter에서 토큰 검증 후 호출 + * + * 로그인 시점에만 계정 상태를 검사하면 정지/휴면/탈퇴 처리된 뒤에도 이미 발급된 토큰으로 + * 계속 이용할 수 있으므로, 매 요청의 토큰 인증에서도 ACTIVE 상태를 요구한다. + * (여기서 던진 예외는 JwtAuthFilter가 401로 변환한다) */ @Override public UserDetails loadUserByUsername( @@ -27,9 +34,21 @@ public UserDetails loadUserByUsername( User user = userRepository.findById(Long.parseLong(userId)) .orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 회원입니다. userId=" + userId)); + validateStatus(user); + return new AuthMember(user); } catch (NumberFormatException e) { throw new UsernameNotFoundException("유효하지 않은 사용자 식별자입니다. userId=" + userId); } } -} \ No newline at end of file + + private void validateStatus(User user) { + UserStatus status = user.getStatus(); + if (status == UserStatus.SUSPENDED) { + throw new LockedException("정지된 계정입니다. userId=" + user.getId()); + } + if (status != UserStatus.ACTIVE) { + throw new DisabledException("이용할 수 없는 계정입니다. userId=" + user.getId() + ", status=" + status); + } + } +} diff --git a/src/test/java/com/umc/bscene/domain/auth/service/auth/AuthServiceTest.java b/src/test/java/com/umc/bscene/domain/auth/service/auth/AuthServiceTest.java index 2bd284ae..5e24be7c 100644 --- a/src/test/java/com/umc/bscene/domain/auth/service/auth/AuthServiceTest.java +++ b/src/test/java/com/umc/bscene/domain/auth/service/auth/AuthServiceTest.java @@ -52,6 +52,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.Mockito.never; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; @@ -865,6 +866,42 @@ void setUp() { .isNotEqualTo(deletedKeyCaptor.getValue()); } + @ParameterizedTest + @CsvSource({ + "SUSPENDED, SUSPENDED_ACCOUNT", + "INACTIVE, INACTIVE_ACCOUNT", + "DELETED, DELETED_ACCOUNT" + }) + void reissue_이용_불가_상태의_계정이면_예외를_던지고_리프레시_토큰을_폐기한다( + UserStatus status, + AuthErrorCode expectedCode + ) { + ReissueRequest request = new ReissueRequest("old-refresh-token"); + User user = user(100L, status); + + when(jwtUtil.isValid(request.refreshToken())) + .thenReturn(true); + when(jwtUtil.getType(request.refreshToken())) + .thenReturn("refresh"); + when(stringRedisTemplate.opsForValue()) + .thenReturn(valueOperations); + when(valueOperations.get(anyString())) + .thenReturn(String.valueOf(user.getId())); + when(userRepository.findById(user.getId())) + .thenReturn(Optional.of(user)); + + AuthException exception = assertThrows( + AuthException.class, + () -> service.reissue(request) + ); + + assertThat(exception.getBaseResponseCode()).isEqualTo(expectedCode); + verify(stringRedisTemplate).delete(startsWith("refreshToken:")); + verify(jwtUtil, never()).createAccessToken(any()); + verify(jwtUtil, never()).createRefreshToken(any()); + verify(valueOperations, never()).set(anyString(), anyString(), any(Duration.class)); + } + // ---------- logout ---------- @Test diff --git a/src/test/java/com/umc/bscene/domain/post/service/PostThumbnailServiceTest.java b/src/test/java/com/umc/bscene/domain/post/service/PostThumbnailServiceTest.java new file mode 100644 index 00000000..61660eef --- /dev/null +++ b/src/test/java/com/umc/bscene/domain/post/service/PostThumbnailServiceTest.java @@ -0,0 +1,75 @@ +package com.umc.bscene.domain.post.service; + +import com.umc.bscene.domain.post.event.PostVideoThumbnailRequestedEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PostThumbnailServiceTest { + + @Mock + private S3Client s3Client; + + @Mock + private PostService postService; + + @InjectMocks + private PostThumbnailService service; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "bucket", "bscene-bucket"); + ReflectionTestUtils.setField(service, "region", "ap-northeast-2"); + } + + // 우리 버킷 호스트가 아니거나, 게시물 미디어 접두사 밖이거나, 영상 확장자가 아니면 S3에 접근조차 하지 않아야 한다 + @ParameterizedTest + @ValueSource(strings = { + "https://attacker-bucket.s3.ap-northeast-2.amazonaws.com/post/a.mp4", + "http://bscene-bucket.s3.ap-northeast-2.amazonaws.com/post/a.mp4", + "https://bscene-bucket.s3.ap-northeast-2.amazonaws.com/user_profile/secret.mp4", + "https://bscene-bucket.s3.ap-northeast-2.amazonaws.com/recordings/live-1/segment.mp4", + "https://bscene-bucket.s3.ap-northeast-2.amazonaws.com/post/../user_profile/x.mp4", + "https://bscene-bucket.s3.ap-northeast-2.amazonaws.com/post/a.jpg", + "https://bscene-bucket.s3.ap-northeast-2.amazonaws.com/post/a.mp4?x=1", + "https://bscene-bucket.s3.ap-northeast-2.amazonaws.com/", + "not a url at all", + "/post/a.mp4" + }) + void 허용되지_않은_영상_URL은_S3에_접근하지_않는다(String videoUrl) { + service.handle(new PostVideoThumbnailRequestedEvent(1L, videoUrl)); + + verifyNoInteractions(s3Client); + verify(postService, never()).applyGeneratedThumbnail(any(), any()); + } + + @Test + void 허용_범위를_넘는_크기의_영상은_내려받지_않는다() { + when(s3Client.headObject(any(HeadObjectRequest.class))) + .thenReturn(HeadObjectResponse.builder().contentLength(600L * 1024 * 1024).build()); + + service.handle(new PostVideoThumbnailRequestedEvent( + 1L, "https://bscene-bucket.s3.ap-northeast-2.amazonaws.com/post/a.mp4")); + + verify(s3Client).headObject(any(HeadObjectRequest.class)); + verify(s3Client, never()).getObject(any(software.amazon.awssdk.services.s3.model.GetObjectRequest.class), + any(java.nio.file.Path.class)); + verify(postService, never()).applyGeneratedThumbnail(any(), any()); + } +} diff --git a/src/test/java/com/umc/bscene/global/security/service/CustomUserDetailsServiceTest.java b/src/test/java/com/umc/bscene/global/security/service/CustomUserDetailsServiceTest.java new file mode 100644 index 00000000..e0082490 --- /dev/null +++ b/src/test/java/com/umc/bscene/global/security/service/CustomUserDetailsServiceTest.java @@ -0,0 +1,92 @@ +package com.umc.bscene.global.security.service; + +import com.umc.bscene.domain.user.entity.User; +import com.umc.bscene.domain.user.enums.Gender; +import com.umc.bscene.domain.user.enums.UserMode; +import com.umc.bscene.domain.user.enums.UserStatus; +import com.umc.bscene.domain.user.repository.UserRepository; +import com.umc.bscene.global.security.entity.AuthMember; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.AccountStatusException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CustomUserDetailsServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private CustomUserDetailsService service; + + @Test + void ACTIVE_유저는_AuthMember로_변환한다() { + User user = user(1L, UserStatus.ACTIVE); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + UserDetails details = service.loadUserByUsername("1"); + + assertThat(details).isInstanceOf(AuthMember.class); + assertThat(((AuthMember) details).getUser()).isSameAs(user); + } + + @Test + void SUSPENDED_유저는_LockedException() { + when(userRepository.findById(1L)).thenReturn(Optional.of(user(1L, UserStatus.SUSPENDED))); + + assertThrows(LockedException.class, () -> service.loadUserByUsername("1")); + } + + @ParameterizedTest + @EnumSource(value = UserStatus.class, names = {"INACTIVE", "DELETED"}) + void INACTIVE_DELETED_유저는_DisabledException(UserStatus status) { + when(userRepository.findById(1L)).thenReturn(Optional.of(user(1L, status))); + + AccountStatusException exception = assertThrows( + DisabledException.class, + () -> service.loadUserByUsername("1") + ); + assertThat(exception.getMessage()).contains(status.name()); + } + + @Test + void 존재하지_않는_유저는_UsernameNotFoundException() { + when(userRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThrows(UsernameNotFoundException.class, () -> service.loadUserByUsername("999")); + } + + @Test + void 숫자가_아닌_식별자는_UsernameNotFoundException() { + assertThrows(UsernameNotFoundException.class, () -> service.loadUserByUsername("abc")); + } + + private User user(Long id, UserStatus status) { + return User.builder() + .id(id) + .name("테스트유저") + .birthDate(LocalDate.of(1999, 1, 1)) + .gender(Gender.MALE) + .phone("01012345678") + .currentMode(UserMode.FAN) + .onboardingCompleted(true) + .status(status) + .build(); + } +}