diff --git a/backend/src/main/java/com/opensource/docgrid/domain/mcp/security/McpApiKeyAuthFilter.java b/backend/src/main/java/com/opensource/docgrid/domain/mcp/security/McpApiKeyAuthFilter.java index 5c44af04..225cc778 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/mcp/security/McpApiKeyAuthFilter.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/mcp/security/McpApiKeyAuthFilter.java @@ -37,10 +37,13 @@ public class McpApiKeyAuthFilter extends OncePerRequestFilter { private final McpAccessTokenCommandService mcpAccessTokenCommandService; - // MCP Streamable HTTP는 응답을 비동기 재디스패치로 처리한다. SecurityContextHolder에만 - // 세팅하면 그 스레드가 끝나는 순간 사라져서, 재디스패치 시점에 SecurityContextHolderFilter가 - // 빈 컨텍스트를 다시 로드해 AuthorizationDeniedException이 발생한다. 요청 attribute에 - // 명시적으로 저장해 재디스패치에서도 같은 인증 정보를 복원할 수 있게 한다. + /** + * MCP Streamable HTTP는 응답을 비동기 재디스패치로 처리한다. {@link SecurityContextHolder}에만 + * 세팅하면 그 스레드가 끝나는 순간 인증 정보가 사라져서, 재디스패치 시점에 + * {@code SecurityContextHolderFilter}가 빈 컨텍스트를 다시 로드해 + * {@code AuthorizationDeniedException}이 발생한다. 요청(request) attribute에 명시적으로 + * 저장해 재디스패치에서도 같은 인증 정보를 복원할 수 있게 한다. + */ private final SecurityContextRepository securityContextRepository = new RequestAttributeSecurityContextRepository(); @Override @@ -53,14 +56,16 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String token = resolveToken(request); // 토큰 추출 + String token = resolveToken(request); // "Authorization: Bearer {토큰}" 헤더에서 토큰 값만 추출 if (StringUtils.hasText(token)) { Optional userId = mcpAccessTokenCommandService.authenticate(token); userId.ifPresent(id -> { - // principal에는 JWT 필터처럼 userId를 바로 넣지 않고 고정 문자열("mcp-client")만 - // 넣는다 — API 키엔 email 같은 신원 표시값이 없어서다. 진짜 userId는 details에 - // 저장하므로, 도구 핸들러에서 사용자를 식별할 땐 getPrincipal()이 아니라 - // getDetails()를 써야 한다. + /* + * principal에는 JWT 필터처럼 userId를 바로 넣지 않고 고정 문자열("mcp-client")만 + * 넣는다 — API 키엔 email 같은 신원 표시값이 없어서다. 진짜 userId는 details에 + * 저장하므로, 도구 핸들러에서 사용자를 식별할 땐 getPrincipal()이 아니라 + * getDetails()를 써야 한다. + */ UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken("mcp-client", null, List.of()); authentication.setDetails(id); @@ -71,8 +76,10 @@ protected void doFilterInternal(HttpServletRequest request, }); } - // 인증 실패(SecurityContext가 비어있음)의 최종 차단은 이 필터가 아니라 SecurityConfig의 - // anyRequest().authenticated() + RestAuthenticationEntryPoint가 401로 응답한다. + /* + * 인증 실패(SecurityContext가 비어있음)의 최종 차단은 이 필터가 아니라 SecurityConfig의 + * anyRequest().authenticated() + RestAuthenticationEntryPoint가 401로 응답한다. + */ filterChain.doFilter(request, response); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/mcp/security/McpRateLimiter.java b/backend/src/main/java/com/opensource/docgrid/domain/mcp/security/McpRateLimiter.java index 58f98345..0641f507 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/mcp/security/McpRateLimiter.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/mcp/security/McpRateLimiter.java @@ -27,9 +27,12 @@ public class McpRateLimiter { // 하나의 카운트 구간(윈도우) 길이 = 60초 = 60,000ms private static final long WINDOW_MILLIS = 60_000; - // 마지막 접근으로부터 이 시간이 지난 사용자·도구 조합은 캐시에서 자동 제거된다. - // rate limit 윈도우(60초)보다 넉넉하게 잡아, 아직 활동 중인 조합이 애매한 타이밍에 - // 지워지는 일이 없도록 여유를 둔다. + /** + * 마지막 접근으로부터 이 시간이 지난 사용자·도구 조합은 캐시에서 자동 제거된다(#292). + * + *

rate limit 윈도우(60초)보다 넉넉하게(2배) 잡아, 아직 활동 중인 조합이 애매한 + * 타이밍에 지워지는 일이 없도록 여유를 둔다. + */ private static final long TTL_MILLIS = TimeUnit.MINUTES.toMillis(2); // "사용자+도구 조합 하나"당 관리해야 하는 카운트 구간 정보를 담는 그릇 @@ -42,9 +45,12 @@ private Window(long windowStartMillis) { } } - // key = "userId:toolName" (예: "5:search_documents") → 그 조합 전용 Window - // 사용자별·도구별로 완전히 독립된 카운터를 갖게 됨 + /** + * key = {@code "userId:toolName"}(예: {@code "5:search_documents"}) → 그 조합 전용 + * {@link Window}. 사용자별·도구별로 완전히 독립된 카운터를 갖게 된다. + */ private final Cache windows; + private final long windowMillis; // 운영 환경에서 Spring이 빈을 만들 때 호출되는 생성자 — 윈도우 길이는 항상 60초로 고정 @@ -52,8 +58,10 @@ public McpRateLimiter() { this(WINDOW_MILLIS, TTL_MILLIS); } - // 테스트에서 윈도우 만료 경계를 짧은 시간 안에 재현할 수 있도록 window 길이를 주입받는다. - // (실제로 60초를 기다릴 수 없으니, 테스트에서만 예: 100ms처럼 짧은 값을 넣어 빠르게 검증) + /** + * 테스트에서 윈도우 만료 경계를 짧은 시간 안에 재현할 수 있도록 window 길이를 주입받는다. + * 실제로 60초를 기다릴 수 없으니, 테스트에서만 예: 100ms처럼 짧은 값을 넣어 빠르게 검증한다. + */ McpRateLimiter(long windowMillis) { this(windowMillis, TTL_MILLIS); } @@ -81,14 +89,16 @@ public void checkLimit(Long userId, String toolName, int limitPerMinute) { long now = System.currentTimeMillis(); Window window = windows.get(key, k -> new Window(now)); - // 만료 판단·리셋·카운트 증가를 synchronized(window) 하나로 묶어야 하는 이유 — 과거엔 - // windowStartMillis/count를 AtomicLong/AtomicInteger로 따로 관리해서 레이스가 있었다. - // 예: 20/20 다 쓴 직후, 윈도우가 막 만료된 순간에 두 요청(21·22번째)이 겹치면: - // 1) 스레드A(21번째)가 만료를 감지해 windowStart만 새 시각으로 갱신 — count=0은 아직 실행 전 - // 2) 그 틈에 스레드B(22번째)가 들어와 "안 만료됨"으로 오판(리셋 스킵) → 옛 count(20)에 증가 - // → 21 > 20 → 새 윈도우의 첫 요청인데 부당하게 차단됨 - // 3) 뒤늦게 스레드A가 count=0 실행 → 스레드B가 방금 남긴 증가(21)까지 통째로 사라짐 - // synchronized(window)로 판단+리셋+증가를 한 덩어리로 묶으면 이 틈 자체가 사라진다. + /* + * 만료 판단·리셋·카운트 증가를 synchronized(window) 하나로 묶어야 하는 이유 — 과거엔 + * windowStartMillis/count를 AtomicLong/AtomicInteger로 따로 관리해서 레이스가 있었다. + * 예: 20/20 다 쓴 직후, 윈도우가 막 만료된 순간에 두 요청(21·22번째)이 겹치면: + * 1) 스레드A(21번째)가 만료를 감지해 windowStart만 새 시각으로 갱신 — count=0은 아직 실행 전 + * 2) 그 틈에 스레드B(22번째)가 들어와 "안 만료됨"으로 오판(리셋 스킵) → 옛 count(20)에 증가 + * → 21 > 20 → 새 윈도우의 첫 요청인데 부당하게 차단됨 + * 3) 뒤늦게 스레드A가 count=0 실행 → 스레드B가 방금 남긴 증가(21)까지 통째로 사라짐 + * synchronized(window)로 판단+리셋+증가를 한 덩어리로 묶으면 이 틈 자체가 사라진다. + */ synchronized (window) { if (now - window.windowStartMillis >= windowMillis) { window.windowStartMillis = now; @@ -101,9 +111,12 @@ public void checkLimit(Long userId, String toolName, int limitPerMinute) { } } - // 테스트 전용 — TTL 만료로 캐시에서 실제로 제거됐는지 확인한다. cleanUp()은 Caffeine이 - // 백그라운드 스레드 없이 다음 접근 시점에야 만료를 정리하는 지연 청소 방식이라, 검증 - // 전에 명시적으로 호출해 즉시 정리를 강제한다. + /** + * 테스트 전용 — TTL 만료로 캐시에서 실제로 제거됐는지 확인한다. + * + *

{@code cleanUp()}은 Caffeine이 백그라운드 스레드 없이 다음 접근 시점에야 만료를 + * 정리하는 지연(lazy) 청소 방식이라, 검증 전에 명시적으로 호출해 즉시 정리를 강제한다. + */ long size() { windows.cleanUp(); return windows.estimatedSize(); diff --git a/backend/src/main/java/com/opensource/docgrid/domain/mcp/tool/DocGridMcpTools.java b/backend/src/main/java/com/opensource/docgrid/domain/mcp/tool/DocGridMcpTools.java index fdc534d6..40b77124 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/mcp/tool/DocGridMcpTools.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/mcp/tool/DocGridMcpTools.java @@ -72,8 +72,10 @@ public DocGridMcpTools(SearchFacade searchFacade, DocumentRepository documentRep this.permissionQueryService = permissionQueryService; this.documentQueryService = documentQueryService; this.rateLimiter = rateLimiter; - // 1. MCP 응답은 null 필드를 제외한다. 앱 전체가 공유하는 ObjectMapper Bean을 직접 바꾸면 - // 다른 REST API 응답에도 영향을 주므로, 이 클래스 전용 복사본에만 설정을 적용한다. + /* + * 1. MCP 응답은 null 필드를 제외한다. 앱 전체가 공유하는 ObjectMapper Bean을 직접 바꾸면 + * 다른 REST API 응답에도 영향을 주므로, 이 클래스 전용 복사본에만 설정을 적용한다. + */ this.objectMapper = objectMapper.copy().setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL); } @@ -84,8 +86,10 @@ public String searchDocuments( @McpToolParam(description = "검색어", required = true) String query, @McpToolParam(description = "반환할 최대 결과 수 (기본 5, 1~20)", required = false) Integer topK) { - // 1. SDK는 required(필수값)를 강제하지 않음이 실측으로 확인됨 (query=null로 그대로 호출됨) - // → null/blank 여부와 비즈니스 규칙(길이/범위)을 전부 여기서 직접 검증한다 + /* + * 1. SDK는 required(필수값)를 강제하지 않음이 실측으로 확인됨 (query=null로 그대로 호출됨) + * → null/blank 여부와 비즈니스 규칙(길이/범위)을 전부 여기서 직접 검증한다 + */ validateSearchInput(query, topK); // 2. 공통 인증·호출 제한·예외 변환 안에서 기존 권한 적용 검색 흐름을 실행한다. @@ -111,14 +115,18 @@ public String getDocumentDetail( throw new DocGridException(ErrorCode.PERMISSION_DENIED); } - // 4. title/status/currentVersion/updatedAt은 Document 엔티티에 이미 있어 직접 사용한다. - // currentVersion은 LAZY라 OSIV가 꺼진 /mcp 경로에서는 findById만 쓰면 트랜잭션 - // 종료 후 LazyInitializationException이 나므로 JOIN FETCH 쿼리를 사용한다. + /* + * 4. title/status/currentVersion/updatedAt은 Document 엔티티에 이미 있어 직접 사용한다. + * currentVersion은 LAZY라 OSIV가 꺼진 /mcp 경로에서는 findById만 쓰면 트랜잭션 + * 종료 후 LazyInitializationException이 나므로 JOIN FETCH 쿼리를 사용한다. + */ Document document = documentRepository.findByIdWithCurrentVersion(documentId) .orElseThrow(() -> new DocGridException(ErrorCode.DOCUMENT_NOT_FOUND)); - // 5. 소프트 삭제된 문서는 존재하지 않는 것과 동일하게 취급한다 — get_indexing_status가 - // 위임하는 DocumentQueryService.getDocumentStatus()와 동일한 처리. + /* + * 5. 소프트 삭제된 문서는 존재하지 않는 것과 동일하게 취급한다 — get_indexing_status가 + * 위임하는 DocumentQueryService.getDocumentStatus()와 동일한 처리. + */ if (document.getStatus() == DocumentStatus.DELETED) { throw new DocGridException(ErrorCode.DOCUMENT_NOT_FOUND); } diff --git a/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java b/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java index 11053c4b..50af2eed 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java @@ -77,9 +77,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authenticationEntryPoint(restAuthenticationEntryPoint) .accessDeniedHandler(restAccessDeniedHandler) ) - // UsernamePasswordAuthenticationFilter는 폼 로그인용이라 실제로는 안 쓰지만, addFilterBefore(A, B.class)가 - // "A를 B보다 앞자리에 꽂아라"는 뜻이라 위치 기준점(앵커)으로만 재사용한다 — 이 필터 앞에 꽂아야 - // 두 인증 필터가 authorizeHttpRequests의 최종 인가 판정보다 먼저 실행돼 SecurityContext를 채울 수 있다. + /* + * UsernamePasswordAuthenticationFilter는 폼 로그인용이라 실제로는 안 쓰지만, addFilterBefore(A, B.class)가 + * "A를 B보다 앞자리에 꽂아라"는 뜻이라 위치 기준점(앵커)으로만 재사용한다 — 이 필터 앞에 꽂아야 + * 두 인증 필터가 authorizeHttpRequests의 최종 인가 판정보다 먼저 실행돼 SecurityContext를 채울 수 있다. + */ .addFilterBefore(new JwtAuthenticationFilter(jwtProvider, tokenBlacklistService, roleAuthorityService), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new McpApiKeyAuthFilter(mcpAccessTokenCommandService), UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/backend/src/main/java/com/opensource/docgrid/global/config/WebMvcConfig.java b/backend/src/main/java/com/opensource/docgrid/global/config/WebMvcConfig.java index fd71b529..fc1b32f9 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/config/WebMvcConfig.java +++ b/backend/src/main/java/com/opensource/docgrid/global/config/WebMvcConfig.java @@ -53,8 +53,17 @@ public void addInterceptors(InterceptorRegistry registry) { } OpenEntityManagerInViewInterceptor interceptor = new OpenEntityManagerInViewInterceptor(); interceptor.setEntityManagerFactory(entityManagerFactory); - // McpApiKeyAuthFilter가 판단하는 /mcp 경로와 동일한 상수를 참조해 두 곳이 - // 서로 다른 경로 문자열로 어긋나지 않게 한다. + /* + * addWebRequestInterceptor()로 이 OSIV 인터셉터를 모든 경로에 등록하되, + * excludePathPatterns()로 /mcp 하나만 등록 대상에서 뺀다 — application.yml의 + * spring.jpa.open-in-view=false로 전역으로 꺼둔 OSIV를, /mcp를 제외한 나머지 + * 경로에서만 이 줄이 다시 켜주는 셈이다(클래스 Javadoc 참고, #120). + * + * "/mcp"라는 경로 문자열을 여기 직접 적지 않고 McpApiKeyAuthFilter.MCP_ENDPOINT + * 상수를 그대로 참조하는 이유: 그 필터도 동일한 "/mcp" 문자열로 자기 담당 경로를 + * 판단하는데, 두 파일에 각각 "/mcp"를 따로 적어두면 나중에 경로가 바뀔 때 한쪽만 + * 고치는 실수로 두 곳이 어긋날 수 있다. 상수 하나를 공유해 그 위험을 없앤다. + */ registry.addWebRequestInterceptor(interceptor).excludePathPatterns(McpApiKeyAuthFilter.MCP_ENDPOINT); } }