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 @@ -9,6 +9,7 @@
import com.recipe.app.src.recipe.exception.NotFoundRecipeException;
import com.recipe.app.src.recipe.exception.NotFoundRecipeLevelException;
import com.recipe.app.src.common.utils.DiscordAlertService;
import com.recipe.app.src.common.utils.ScanAttackMonitor;
import com.recipe.app.src.user.exception.ForbiddenAccessException;
import com.recipe.app.src.user.exception.NotFoundUserException;
import com.recipe.app.src.user.exception.UserTokenNotExistException;
Expand All @@ -18,8 +19,11 @@
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.web.firewall.RequestRejectedException;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.servlet.resource.NoResourceFoundException;

import java.util.Map;

Expand All @@ -30,9 +34,12 @@ public class GeneralExceptionHandler {

// DiscordAlertService 는 prod 프로필에서만 빈으로 등록되므로 ObjectProvider 로 안전하게 주입한다.
private final ObjectProvider<DiscordAlertService> discordAlertServiceProvider;
private final ScanAttackMonitor scanAttackMonitor;

public GeneralExceptionHandler(ObjectProvider<DiscordAlertService> discordAlertServiceProvider) {
public GeneralExceptionHandler(ObjectProvider<DiscordAlertService> discordAlertServiceProvider,
ScanAttackMonitor scanAttackMonitor) {
this.discordAlertServiceProvider = discordAlertServiceProvider;
this.scanAttackMonitor = scanAttackMonitor;
}

@ExceptionHandler({NotFoundFridgeException.class, NotFoundIngredientException.class, NotFoundFridgeBasketException.class,
Expand All @@ -55,17 +62,46 @@ public ResponseEntity<?> handleBadRequestException(Exception e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e);
}

// 위에서 처리되지 않은 모든 예외 = 500. 로그를 남기고 (prod 한정) Discord 로 알림을 보낸다.
// 존재하지 않는 경로 요청(취약점 스캐너/봇 등). 404 로 조용히 처리하고 알림은 보내지 않는다.
// 대량 공격 관찰용으로 IP·경로는 WARN 으로 남긴다.
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<?> handleNoResourceFound(NoResourceFoundException e, HttpServletRequest request) {
String clientIp = clientIp(request);
log.warn("No static resource. {} {} from {}", request.getMethod(), request.getRequestURI(), clientIp);
scanAttackMonitor.record(clientIp);
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}

// 방화벽(StrictHttpFirewall)이 막은 비정상 요청(잘못된 파라미터/경로 등). 400 으로 처리하고 알림은 보내지 않는다.
@ExceptionHandler(RequestRejectedException.class)
public ResponseEntity<?> handleRequestRejected(RequestRejectedException e, HttpServletRequest request) {
String clientIp = clientIp(request);
log.warn("Request rejected by firewall. {} {} from {} - {}", request.getMethod(), request.getRequestURI(), clientIp, e.getMessage());
scanAttackMonitor.record(clientIp);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("message", "잘못된 요청입니다."));
}

// 위에서 처리되지 않은 모든 예외 = 500. 로그를 남기고 (webhook 설정 시) Discord 로 알림을 보낸다.
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleInternalServerError(Exception e, HttpServletRequest request) {
log.error("Internal Server Error. {} {}", request.getMethod(), request.getRequestURI(), e);
String clientIp = clientIp(request);
log.error("Internal Server Error. {} {} from {}", request.getMethod(), request.getRequestURI(), clientIp, e);

DiscordAlertService discordAlertService = discordAlertServiceProvider.getIfAvailable();
if (discordAlertService != null) {
discordAlertService.sendErrorAlert(request.getMethod(), request.getRequestURI(), e);
discordAlertService.sendErrorAlert(request.getMethod(), request.getRequestURI(), clientIp, e);
}

return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "서버 오류가 발생했습니다."));
}

// 프록시(nginx/ELB) 뒤에 있을 수 있으므로 X-Forwarded-For 를 우선 확인한다.
private String clientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (StringUtils.hasText(forwarded)) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,13 @@ public class DiscordAlertService {
/**
* 처리되지 않은 예외(500) 알림.
*/
public void sendErrorAlert(String httpMethod, String requestUri, Throwable throwable) {
public void sendErrorAlert(String httpMethod, String requestUri, String clientIp, Throwable throwable) {
Map<String, Object> embed = Map.of(
"title", "🚨 500 Internal Server Error",
"color", RED,
"fields", List.of(
field("Endpoint", httpMethod + " " + requestUri),
field("Client IP", clientIp),
field("Exception", throwable.getClass().getSimpleName()),
field("Message", truncate(throwable.getMessage())),
field("Stacktrace", "```" + stackTrace(throwable) + "```")
Expand All @@ -74,6 +75,22 @@ public void sendReportAlert(long recipeId, long reporterUserId, long reportCount
send(reportWebhookUrl, embed);
}

/**
* 스캔성 요청 급증 감지 알림 (개별 요청이 아니라 집계 결과 1회 전송).
* 드물게 발생하므로 별도 채널 없이 500 에러 채널로 보내되 제목/색으로 구분한다.
*/
public void sendScanSurgeAlert(int count, long windowMinutes, List<String> topIps) {
Map<String, Object> embed = Map.of(
"title", "⚠️ 스캔성 요청 급증 감지",
"color", ORANGE,
"fields", List.of(
field("감지", "최근 " + windowMinutes + "분간 " + count + "건 (임계치 초과)"),
field("Top IP", topIps.isEmpty() ? "(none)" : String.join("\n", topIps))
)
);
send(errorWebhookUrl, embed);
}

private void send(String webhookUrl, Map<String, Object> embed) {
if (!StringUtils.hasText(webhookUrl)) {
log.warn("Discord webhook URL 이 설정되지 않아 알림을 건너뜁니다.");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.recipe.app.src.common.utils;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
* 스캔성(존재하지 않는 경로 / 방화벽 차단) 요청을 집계해, 일정 시간 안에 임계치를 넘으면
* Discord 로 "급증" 알림을 1회 보낸다. 개별 요청마다 알림을 보내지 않으므로 채널이 노이즈로 덮이지 않는다.
*
* 스케줄러 없이 요청 스레드에서 슬라이딩(tumbling) 윈도우를 갱신하며, 재알림은 쿨다운으로 억제한다.
*/
@Component
public class ScanAttackMonitor {

private static final long WINDOW_MS = 5 * 60 * 1000L; // 집계 윈도우 5분
private static final int THRESHOLD = 100; // 윈도우당 임계치
private static final long COOLDOWN_MS = 10 * 60 * 1000L; // 재알림 최소 간격 10분
private static final int TOP_IP_COUNT = 5;

// DiscordAlertService 는 webhook 미설정/비활성 환경에서 없을 수 있으므로 ObjectProvider 로 안전하게 주입한다.
private final ObjectProvider<DiscordAlertService> discordAlertServiceProvider;

private long windowStart = System.currentTimeMillis();
private int count = 0;
private final Map<String, Integer> ipCounts = new HashMap<>();
private long lastAlertAt = 0L;

public ScanAttackMonitor(ObjectProvider<DiscordAlertService> discordAlertServiceProvider) {
this.discordAlertServiceProvider = discordAlertServiceProvider;
}

public synchronized void record(String clientIp) {
long now = System.currentTimeMillis();

// 윈도우가 지났으면 초기화
if (now - windowStart > WINDOW_MS) {
windowStart = now;
count = 0;
ipCounts.clear();
}

count++;
ipCounts.merge(clientIp != null ? clientIp : "unknown", 1, Integer::sum);

if (count >= THRESHOLD && now - lastAlertAt > COOLDOWN_MS) {
lastAlertAt = now;
DiscordAlertService discordAlertService = discordAlertServiceProvider.getIfAvailable();
if (discordAlertService != null) {
discordAlertService.sendScanSurgeAlert(count, WINDOW_MS / 60000, topIps());
}
}
}

private List<String> topIps() {
return ipCounts.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
.limit(TOP_IP_COUNT)
.map(e -> e.getKey() + " (" + e.getValue() + "건)")
.collect(Collectors.toList());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import com.recipe.app.src.recipe.application.RecipeSearchService;
import com.recipe.app.src.recipe.application.dto.RecipeDetailResponse;
import com.recipe.app.src.recipe.application.dto.RecommendedRecipesResponse;
import com.recipe.app.src.recipe.application.dto.RecipesResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
Expand Down Expand Up @@ -35,7 +35,7 @@ public RecipeDetailResponse getPublicRecipe(@PathVariable long recipeId) {

@Operation(summary = "공개 추천 레시피 목록 조회 API (로그인 불필요)")
@GetMapping("/recommendation")
public RecommendedRecipesResponse getPublicRecipesByIngredients(
public RecipesResponse getPublicRecipesByIngredients(
@Parameter(example = "감자,당근,양파", name = "재료 목록 (쉼표로 구분)")
@RequestParam(value = "ingredients") String ingredients,
@Parameter(example = "0", name = "마지막 조회 레시피 아이디")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import com.recipe.app.src.recipe.application.dto.RecipeDetailResponse;
import com.recipe.app.src.recipe.application.dto.RecipeRequest;
import com.recipe.app.src.recipe.application.dto.RecipesResponse;
import com.recipe.app.src.recipe.application.dto.RecommendedRecipesResponse;
import com.recipe.app.src.user.domain.User;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
Expand Down Expand Up @@ -89,7 +88,7 @@ public RecipesResponse getScrapRecipes(@Parameter(hidden = true) User user,
@Operation(summary = "냉장고 파먹기 레시피 목록 조회 API")
@GetMapping("/fridges-recommendation")
@LoginCheck
public RecommendedRecipesResponse getFridgesRecipes(@Parameter(hidden = true) User user,
public RecipesResponse getFridgesRecipes(@Parameter(hidden = true) User user,
@Parameter(example = "0", name = "마지막 조회 레시피 아이디")
@RequestParam(value = "startAfter") long startAfter,
@Parameter(example = "20", name = "사이즈")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import com.recipe.app.src.ingredient.application.IngredientSynonymCache;
import com.recipe.app.src.recipe.application.dto.RecipeDetailResponse;
import com.recipe.app.src.recipe.application.dto.RecipesResponse;
import com.recipe.app.src.recipe.application.dto.RecommendedRecipesResponse;
import com.recipe.app.src.recipe.domain.Recipe;
import com.recipe.app.src.recipe.domain.RecipeScrap;
import com.recipe.app.src.recipe.domain.Recipes;
Expand Down Expand Up @@ -125,9 +124,15 @@ public RecipesResponse findRecipesByUser(User user, long lastRecipeId, int size)

long totalCnt = recipeRepository.countByUserId(user.getUserId());

List<Recipe> recipes = findLimitByUserId(user.getUserId(), lastRecipeId, size);
Recipes recipes = new Recipes(findLimitByUserId(user.getUserId(), lastRecipeId, size));

return getRecipes(user, totalCnt, new Recipes(recipes));
List<User> recipePostUsers = userService.findByUserIds(recipes.getUserIds());

List<RecipeScrap> recipeScraps = recipeScrapService.findByRecipeIds(recipes.getRecipeIds());

List<String> ingredientNamesInFridge = fridgeService.findIngredientNamesInFridge(user.getUserId());

return RecipesResponse.from(totalCnt, recipes, recipePostUsers, recipeScraps, user, ingredientNamesInFridge);
}

@Transactional(readOnly = true)
Expand All @@ -146,7 +151,7 @@ private RecipesResponse getRecipes(User user, long totalCnt, Recipes recipes) {
}

@Transactional(readOnly = true)
public RecommendedRecipesResponse findRecommendedRecipesByUserFridge(User user, long lastRecipeId, int size) {
public RecipesResponse findRecommendedRecipesByUserFridge(User user, long lastRecipeId, int size) {

List<String> ingredientNamesInFridge = fridgeService.findIngredientNamesInFridge(user.getUserId());

Expand All @@ -158,11 +163,11 @@ public RecommendedRecipesResponse findRecommendedRecipesByUserFridge(User user,

Recipe lastRecipe = recipeRepository.findById(lastRecipeId).orElse(null);

return RecommendedRecipesResponse.from(recipes, recipePostUsers, recipeScraps, user, ingredientNamesInFridge, lastRecipe, size);
return RecipesResponse.from(recipes, recipePostUsers, recipeScraps, user, ingredientNamesInFridge, lastRecipe, size);
}

@Transactional(readOnly = true)
public RecommendedRecipesResponse findPublicRecommendedRecipesByIngredients(List<String> ingredientNames, long lastRecipeId, int size) {
public RecipesResponse findPublicRecommendedRecipesByIngredients(List<String> ingredientNames, long lastRecipeId, int size) {

List<String> expandedIngredientNames = List.copyOf(ingredientSynonymCache.expand(ingredientNames));

Expand All @@ -176,6 +181,6 @@ public RecommendedRecipesResponse findPublicRecommendedRecipesByIngredients(List

User anonymousUser = new User();

return RecommendedRecipesResponse.from(recipes, recipePostUsers, recipeScraps, anonymousUser, expandedIngredientNames, lastRecipe, size);
return RecipesResponse.from(recipes, recipePostUsers, recipeScraps, anonymousUser, expandedIngredientNames, lastRecipe, size);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ public class RecipeResponse {
private final long scrapCnt;
@Schema(description = "조회수")
private final long viewCnt;
@Schema(description = "재료 일치도")
private final long ingredientsMatchRate;

@Builder
public RecipeResponse(Long recipeId, String recipeName, String introduction, String thumbnailImgUrl, String postUserName, String postDate,
String linkUrl, Boolean isUserScrap, long scrapCnt, long viewCnt) {
String linkUrl, Boolean isUserScrap, long scrapCnt, long viewCnt, long ingredientsMatchRate) {

this.recipeId = recipeId;
this.recipeName = recipeName;
Expand All @@ -53,10 +55,16 @@ public RecipeResponse(Long recipeId, String recipeName, String introduction, Str
this.isUserScrap = isUserScrap;
this.scrapCnt = scrapCnt;
this.viewCnt = viewCnt;
this.ingredientsMatchRate = ingredientsMatchRate;
}

public static RecipeResponse from(Recipe recipe, User recipePostUser, List<RecipeScrap> recipeScraps, User user) {

return from(recipe, recipePostUser, recipeScraps, user, 0L);
}

public static RecipeResponse from(Recipe recipe, User recipePostUser, List<RecipeScrap> recipeScraps, User user, long ingredientsMatchRate) {

return RecipeResponse.builder()
.recipeId(recipe.getRecipeId())
.recipeName(recipe.getRecipeNm())
Expand All @@ -70,6 +78,7 @@ public static RecipeResponse from(Recipe recipe, User recipePostUser, List<Recip
&& recipeScrap.getUserId().equals(user.getUserId())))
.scrapCnt(recipe.getScrapCnt())
.viewCnt(recipe.getViewCnt())
.ingredientsMatchRate(ingredientsMatchRate)
.build();
}

Expand Down
Loading
Loading