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 @@ -19,6 +19,7 @@
import soon.fridgely.domain.food.service.FoodService;
import soon.fridgely.domain.refrigerator.dto.command.MemberRefrigeratorKey;
import soon.fridgely.global.security.annotation.LoginMember;
import soon.fridgely.global.security.ratelimit.UploadRateLimit;
import soon.fridgely.global.support.CursorPageRequest;
import soon.fridgely.global.support.response.ApiResponse;

Expand All @@ -31,6 +32,7 @@ public class FoodController implements FoodControllerDocs {
private final FoodService foodService;

@Override
@UploadRateLimit
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<?>> createFood(
@RequestPart(value = "request") @Valid FoodCreateRequest request,
Expand All @@ -43,6 +45,7 @@ public ResponseEntity<ApiResponse<?>> createFood(
}

@Override
@UploadRateLimit
@PatchMapping(value = "/{foodId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<?>> updateFood(
@RequestPart(value = "request") @Valid FoodUpdateRequest request,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import soon.fridgely.domain.member.service.MemberFacade;
import soon.fridgely.domain.member.service.MemberService;
import soon.fridgely.global.security.annotation.LoginMember;
import soon.fridgely.global.security.ratelimit.UploadRateLimit;
import soon.fridgely.global.support.response.ApiResponse;

@RequiredArgsConstructor
Expand Down Expand Up @@ -54,6 +55,7 @@ public ResponseEntity<ApiResponse<?>> syncToken(
}

@Override
@UploadRateLimit
@PatchMapping(value = "/me/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<?>> updateProfileImage(
@RequestPart("file") MultipartFile file,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package soon.fridgely.global.security.ratelimit;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UploadRateLimit {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package soon.fridgely.global.security.ratelimit;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import soon.fridgely.global.support.exception.CoreException;
import soon.fridgely.global.support.exception.ErrorType;
import soon.fridgely.global.support.logging.SlackMarkers;

import java.time.Duration;

@Slf4j
@RequiredArgsConstructor
@Aspect
@Component
@ConditionalOnProperty(name = "spring.cache.type", havingValue = "redis")
public class UploadRateLimitAspect {

private final StringRedisTemplate stringRedisTemplate;

@Value("${upload.rate-limit.max-requests}")
private int maxRequests;

@Value("${upload.rate-limit.period-seconds}")
private long periodSeconds;

@Before("@annotation(soon.fridgely.global.security.ratelimit.UploadRateLimit)")
public void checkRateLimit() {
Long userId = extractUserId();
String key = "upload:ratelimit:" + userId;

Long count = stringRedisTemplate.opsForValue().increment(key);
Long ttl = stringRedisTemplate.getExpire(key);
if (ttl != null && ttl == -1L) {
stringRedisTemplate.expire(key, Duration.ofSeconds(periodSeconds));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (count != null && count > maxRequests) {
log.warn(SlackMarkers.SYSTEM, "[UploadRateLimitAspect] 업로드 Rate Limit 초과. (UserId={}, Count={})", userId, count);
throw new CoreException(ErrorType.UPLOAD_RATE_LIMIT_EXCEEDED);
}
}

private Long extractUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()
|| authentication instanceof AnonymousAuthenticationToken) {
throw new CoreException(ErrorType.AUTHENTICATION_FAILED);
}
Object principal = authentication.getPrincipal();
if (principal instanceof String str) {
try {
return Long.parseLong(str);
} catch (NumberFormatException e) {
throw new CoreException(ErrorType.AUTHENTICATION_FAILED);
}
}
throw new CoreException(ErrorType.AUTHENTICATION_FAILED);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public enum ErrorType {
FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "파일 크기가 허용 범위를 초과했습니다. (최대 10MB)", LogLevel.WARN),
INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "허용되지 않은 파일 형식입니다.", LogLevel.WARN),
INVALID_IMAGE_URL(HttpStatus.BAD_REQUEST, "유효하지 않은 이미지 URL입니다.", LogLevel.WARN),
UPLOAD_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "업로드 요청이 너무 많습니다. 잠시 후 다시 시도해주세요.", LogLevel.WARN),

// 멤버 오류
DUPLICATE_LOGIN_ID(HttpStatus.CONFLICT, "이미 사용 중인 ID입니다.", LogLevel.WARN),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,40 +7,32 @@
import soon.fridgely.global.support.exception.CoreException;
import soon.fridgely.global.support.exception.ErrorType;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.List;
import java.util.Map;

/**
* 이미지 파일 검증을 담당
* - 파일 크기 검증
* - Content-Type 검증
* - 파일 확장자 검증
* - Magic Number 검증 (파일 위변조 방지)
*/
@Slf4j
@Component
public class ImageValidator {

private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
private static final List<String> ALLOWED_EXTENSIONS = List.of("jpg", "jpeg", "png", "gif", "webp");
private static final List<String> ALLOWED_CONTENT_TYPES = List.of(
"image/jpeg", "image/png", "image/gif", "image/webp"
);
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024;
private static final List<String> ALLOWED_EXTENSIONS = List.of("jpg", "jpeg", "png");
private static final List<String> ALLOWED_CONTENT_TYPES = List.of("image/jpeg", "image/png");
private static final Map<String, byte[]> MAGIC_NUMBERS = Map.of(
"image/jpeg", new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF},
"image/png", new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47},
"image/gif", new byte[]{0x47, 0x49, 0x46, 0x38},
"image/webp", new byte[]{0x52, 0x49, 0x46, 0x46}
"image/png", new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47}
);

public void validate(MultipartFile file) {
validateFileSize(file);
validateContentType(file);
validateFileExtension(file);
validateMagicNumber(file);
validateImageParseable(file);
}

private void validateFileSize(MultipartFile file) {
Expand All @@ -61,7 +53,6 @@ private void validateFileExtension(MultipartFile file) {
if (!StringUtils.hasText(originalFilename)) {
throw new CoreException(ErrorType.INVALID_FILE_TYPE);
}

String extension = getFileExtension(originalFilename).toLowerCase();
if (!ALLOWED_EXTENSIONS.contains(extension)) {
throw new CoreException(ErrorType.INVALID_FILE_TYPE);
Expand All @@ -73,15 +64,12 @@ private void validateMagicNumber(MultipartFile file) {
if (contentType == null) {
return;
}

byte[] expectedMagicNumber = MAGIC_NUMBERS.get(contentType);
if (expectedMagicNumber == null) {
return;
}

try (InputStream is = file.getInputStream()) {
byte[] fileHeader = is.readNBytes(expectedMagicNumber.length);

if (!Arrays.equals(fileHeader, expectedMagicNumber)) {
log.debug("[ImageValidator] Magic Number 불일치. (ContentType={})", contentType);
throw new CoreException(ErrorType.INVALID_FILE_TYPE);
Expand All @@ -92,12 +80,29 @@ private void validateMagicNumber(MultipartFile file) {
}
}

private void validateImageParseable(MultipartFile file) {
try (InputStream is = file.getInputStream()) {
BufferedImage image = ImageIO.read(is);
if (image == null) {
log.debug("[ImageValidator] ImageIO 파싱 실패 — null 반환. (Filename={})", file.getOriginalFilename());
throw new CoreException(ErrorType.INVALID_FILE_TYPE);
}
} catch (CoreException e) {
throw e;
} catch (IOException e) {
log.debug("[ImageValidator] ImageIO 파싱 실패 — IOException. (Filename={})", file.getOriginalFilename());
throw new CoreException(ErrorType.INVALID_FILE_TYPE);
} catch (RuntimeException e) {
log.debug("[ImageValidator] ImageIO 파싱 실패 — RuntimeException. (Filename={})", file.getOriginalFilename());
throw new CoreException(ErrorType.INVALID_FILE_TYPE);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private String getFileExtension(String filename) {
int lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex == -1 || lastDotIndex == filename.length() - 1) {
return "";
}
return filename.substring(lastDotIndex + 1);
}

}
}
5 changes: 5 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ management:
scheduling:
enabled: true

upload:
rate-limit:
max-requests: 10
period-seconds: 60

# 공통 Resilience4j 설정
resilience4j:
retry:
Expand Down
Loading
Loading