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 @@ -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
Expand All @@ -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<Long> 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);
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ public class McpRateLimiter {
// 하나의 카운트 구간(윈도우) 길이 = 60초 = 60,000ms
private static final long WINDOW_MILLIS = 60_000;

// 마지막 접근으로부터 이 시간이 지난 사용자·도구 조합은 캐시에서 자동 제거된다.
// rate limit 윈도우(60초)보다 넉넉하게 잡아, 아직 활동 중인 조합이 애매한 타이밍에
// 지워지는 일이 없도록 여유를 둔다.
/**
* 마지막 접근으로부터 이 시간이 지난 사용자·도구 조합은 캐시에서 자동 제거된다(#292).
*
* <p>rate limit 윈도우(60초)보다 넉넉하게(2배) 잡아, 아직 활동 중인 조합이 애매한
* 타이밍에 지워지는 일이 없도록 여유를 둔다.
*/
private static final long TTL_MILLIS = TimeUnit.MINUTES.toMillis(2);

// "사용자+도구 조합 하나"당 관리해야 하는 카운트 구간 정보를 담는 그릇
Expand All @@ -42,18 +45,23 @@ private Window(long windowStartMillis) {
}
}

// key = "userId:toolName" (예: "5:search_documents") → 그 조합 전용 Window
// 사용자별·도구별로 완전히 독립된 카운터를 갖게 됨
/**
* key = {@code "userId:toolName"}(예: {@code "5:search_documents"}) → 그 조합 전용
* {@link Window}. 사용자별·도구별로 완전히 독립된 카운터를 갖게 된다.
*/
private final Cache<String, Window> windows;

private final long windowMillis;

// 운영 환경에서 Spring이 빈을 만들 때 호출되는 생성자 — 윈도우 길이는 항상 60초로 고정
public McpRateLimiter() {
this(WINDOW_MILLIS, TTL_MILLIS);
}

// 테스트에서 윈도우 만료 경계를 짧은 시간 안에 재현할 수 있도록 window 길이를 주입받는다.
// (실제로 60초를 기다릴 수 없으니, 테스트에서만 예: 100ms처럼 짧은 값을 넣어 빠르게 검증)
/**
* 테스트에서 윈도우 만료 경계를 짧은 시간 안에 재현할 수 있도록 window 길이를 주입받는다.
* 실제로 60초를 기다릴 수 없으니, 테스트에서만 예: 100ms처럼 짧은 값을 넣어 빠르게 검증한다.
*/
McpRateLimiter(long windowMillis) {
this(windowMillis, TTL_MILLIS);
}
Expand Down Expand Up @@ -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;
Expand All @@ -101,9 +111,12 @@ public void checkLimit(Long userId, String toolName, int limitPerMinute) {
}
}

// 테스트 전용 — TTL 만료로 캐시에서 실제로 제거됐는지 확인한다. cleanUp()은 Caffeine이
// 백그라운드 스레드 없이 다음 접근 시점에야 만료를 정리하는 지연 청소 방식이라, 검증
// 전에 명시적으로 호출해 즉시 정리를 강제한다.
/**
* 테스트 전용 — TTL 만료로 캐시에서 실제로 제거됐는지 확인한다.
*
* <p>{@code cleanUp()}은 Caffeine이 백그라운드 스레드 없이 다음 접근 시점에야 만료를
* 정리하는 지연(lazy) 청소 방식이라, 검증 전에 명시적으로 호출해 즉시 정리를 강제한다.
*/
long size() {
windows.cleanUp();
return windows.estimatedSize();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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. 공통 인증·호출 제한·예외 변환 안에서 기존 권한 적용 검색 흐름을 실행한다.
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}