Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -18,6 +21,10 @@ public class CustomUserDetailsService implements UserDetailsService {
/**
* JWT subject(userId)로 유저를 조회해 UserDetails로 변환
* JwtAuthFilter에서 토큰 검증 후 호출
*
* 로그인 시점에만 계정 상태를 검사하면 정지/휴면/탈퇴 처리된 뒤에도 이미 발급된 토큰으로
* 계속 이용할 수 있으므로, 매 요청의 토큰 인증에서도 ACTIVE 상태를 요구한다.
* (여기서 던진 예외는 JwtAuthFilter가 401로 변환한다)
*/
@Override
public UserDetails loadUserByUsername(
Expand All @@ -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);
}
}
}

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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading