From 1917df09a2d014ce249b8a6af041775c4974d7fd Mon Sep 17 00:00:00 2001 From: devmrko Date: Thu, 23 Jul 2026 14:45:18 +0900 Subject: [PATCH] refs #712: authenticate backoffice MCP user tokens --- .../702-hmm-backoffice-identity/README.md | 2 +- .../712-hmm-backoffice-mcp-bearer/README.md | 78 ++++++++++ ...-hmm-backoffice-mcp-bearer-verification.md | 60 ++++++++ docs/runbooks/hmm-mcp-token-configuration.md | 140 ++++++++++++++++++ .../service/HmmAiAgentToolRunner.java | 2 +- .../service/HmmMcpBearerAuthenticator.java | 8 + .../service/HmmMcpPrincipal.java | 9 ++ .../service/JdbcHmmAiAgentToolRunner.java | 49 +++++- .../JdbcHmmMcpBearerAuthenticator.java | 45 ++++++ .../vpdbackoffice/service/McpSseService.java | 22 +-- .../service/McpUnauthorizedException.java | 9 ++ .../vpdbackoffice/web/McpSseController.java | 26 +++- src/main/resources/application.yml | 2 +- src/main/resources/templates/mcp-sse.html | 4 +- src/main/resources/templates/settings.html | 2 +- .../service/JdbcHmmAiAgentToolRunnerTest.java | 65 ++++++++ .../service/McpSseServiceTest.java | 35 ++++- .../web/SettingsTemplateRenderTest.java | 4 +- 18 files changed, 525 insertions(+), 37 deletions(-) create mode 100644 docs/design/712-hmm-backoffice-mcp-bearer/README.md create mode 100644 docs/reports/2026-07-23-hmm-backoffice-mcp-bearer-verification.md create mode 100644 docs/runbooks/hmm-mcp-token-configuration.md create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpBearerAuthenticator.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpPrincipal.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmMcpBearerAuthenticator.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/McpUnauthorizedException.java create mode 100644 src/test/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunnerTest.java diff --git a/docs/design/702-hmm-backoffice-identity/README.md b/docs/design/702-hmm-backoffice-identity/README.md index 2420d96..0623966 100644 --- a/docs/design/702-hmm-backoffice-identity/README.md +++ b/docs/design/702-hmm-backoffice-identity/README.md @@ -75,7 +75,7 @@ HMM_KNOWLEDGE_DOCUMENTS ──< HMM_KNOWLEDGE_CHUNKS ──< HMM_KNOWLEDGE_TAGS ## HMM MCP 및 시스템 설정 - 운영 MCP 주소는 환경변수 `BACKOFFICE_HMM_MCP_PUBLIC_URL`로 관리하며 기본값은 - `https://hmm-mcp.cloud-handson.com/mcp`이다. 시스템 설정과 MCP 연동 화면은 이 값을 표시하므로 + 사용자 토큰용 주소는 `https://hmm-backoffice.cloud-handson.com/mcp`이다. 시스템 설정과 MCP 연동 화면은 이 값을 표시하므로 도메인 변경 시 화면 소스를 수정하지 않는다. - HMM MCP와 백오피스 호환 `/mcp`는 동일하게 `resolve_hr_term`, `search_hr_data`, `search_hr_policy`를 제공한다. 각각 `HMM_HR_TERM_RESOLVER`, diff --git a/docs/design/712-hmm-backoffice-mcp-bearer/README.md b/docs/design/712-hmm-backoffice-mcp-bearer/README.md new file mode 100644 index 0000000..1cf02a8 --- /dev/null +++ b/docs/design/712-hmm-backoffice-mcp-bearer/README.md @@ -0,0 +1,78 @@ +# HMM 백오피스 MCP 사용자 Bearer 인증 설계 (#712) + +> 상태: 구현·배포·검증 완료 +> 대상: `https://hmm-backoffice.cloud-handson.com/mcp` +> 브랜치: `hmm-backoffice` + +## 목적 + +HMM 백오피스에서 발급한 `vpd_live_*` 사용자 토큰을 MCP의 실제 인증 수단으로 사용한다. +현재 별도 호스트의 `https://hmm-mcp.cloud-handson.com/mcp`는 서버 공용 게이트웨이 토큰만 +허용하므로 사용자 토큰을 보내면 Nginx에서 HTTP 401을 반환한다. 사용자별 HMM 데모는 +백오피스 MCP 주소를 사용해야 한다. + +## 확인된 현행 결함 + +- 백오피스의 `/mcp`는 `initialize`, `tools/list`, `tools/call`을 제공한다. +- Controller가 `Authorization: Bearer` 값을 추출하지만 Service의 + `ignoredAuthorization` 인자로 전달해 검증하지 않는다. +- Tool 실행은 토큰 사용자 컨텍스트를 설정하지 않고 백오피스 JDBC 계정으로 바로 + `DBMS_CLOUD_AI_AGENT.RUN_TOOL`을 호출한다. +- 따라서 주소를 올바르게 사용해도 토큰이 인증과 행 접근 문맥에 연결되지 않는다. + +## 변경 설계 + +```text +Private Agent Factory + → POST https://hmm-backoffice.cloud-handson.com/mcp + → Authorization: Bearer + → HMM_ACCESS_BEARER_TOKENS SHA-256/만료/회수/재직 검증 + → 동일 JDBC connection에서 HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER + → DBMS_CLOUD_AI_AGENT.RUN_TOOL + → finally HMM_ACCESS_CTX_PKG.CLEAR_USER +``` + +1. 모든 MCP method는 활성 사용자 Bearer를 요구한다. 누락·오류·만료·회수 토큰은 HTTP 401로 + fail-closed한다. +2. 원문 토큰은 로그, 응답, DB, Git에 기록하지 않는다. DB에는 기존 SHA-256 해시만 사용한다. +3. `tools/call`은 토큰 검증과 Tool 실행을 같은 요청에서 수행한다. +4. Tool 실행 connection에는 컨텍스트를 설정하고 성공·실패와 무관하게 `finally`에서 지운다. +5. `initialize`와 `tools/list`도 토큰을 검증해 discovery만으로 인증을 우회할 수 없게 한다. + +## Agent Factory 설정 + +| 항목 | 값 | +|---|---| +| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` | +| Authentication mode | `Bearer Token` | +| Bearer token | 백오피스에서 발급한 토큰 원문만 입력 (`Bearer ` 접두어 제외) | + +별도 서버 `https://hmm-mcp.cloud-handson.com/mcp`에는 사용자 토큰을 사용하지 않는다. 그 주소는 +운영 `HMM_MCP_BEARER_TOKEN`을 사용하는 호환 게이트웨이다. + +## 완료 기준 + +- 활성 사용자 토큰으로 `initialize`, `tools/list`, 세 Tool 호출이 성공한다. +- 누락·무효·회수 토큰은 HTTP 401이다. +- Tool 호출 전후 DB context 설정·정리가 자동 테스트로 검증된다. +- 운영 배포 후 외부 HTTPS에서 discovery와 대표 Tool 호출을 검증한다. +- 토큰 원문이나 해시는 테스트 출력과 문서에 남지 않는다. + +## 배포 검증 결과 + +2026-07-23 운영 배포에서 다음을 확인했다. + +- 자동 테스트 107건 통과 +- 운영 JAR과 검증 빌드 SHA-256 일치 +- 무토큰 `initialize`, `tools/list`: HTTP 401 +- 활성 E1002 임시 사용자 토큰: + - `initialize`: HTTP 200 + - `tools/list`: HTTP 200 + - `resolve_hr_term`: HTTP 200, 정상 MCP result + - 발견 도구: `resolve_hr_term`, `search_hr_data`, `search_hr_policy` +- 같은 토큰을 회수한 직후 `tools/list`: HTTP 401 +- 검증 중 발급한 임시 토큰과 이전 실패 시 남은 임시 토큰을 모두 회수 +- 백오피스 시스템 설정과 MCP 서비스 화면의 Agent Factory 주소를 + `https://hmm-backoffice.cloud-handson.com/mcp`로 변경 + +상세 증거는 `docs/reports/2026-07-23-hmm-backoffice-mcp-bearer-verification.md`에 기록한다. diff --git a/docs/reports/2026-07-23-hmm-backoffice-mcp-bearer-verification.md b/docs/reports/2026-07-23-hmm-backoffice-mcp-bearer-verification.md new file mode 100644 index 0000000..9499f25 --- /dev/null +++ b/docs/reports/2026-07-23-hmm-backoffice-mcp-bearer-verification.md @@ -0,0 +1,60 @@ +# HMM 백오피스 MCP 사용자 Bearer 검증 보고서 + +- 일자: 2026-07-23 +- Redmine: #712 +- 브랜치: `hmm-backoffice` +- 서비스: `https://hmm-backoffice.cloud-handson.com/mcp` + +## 원인 + +백오피스에서 발급한 `vpd_live_*` 직원 토큰을 별도 호환 서버인 +`https://hmm-mcp.cloud-handson.com/mcp`에 보냈다. 이 서버의 Nginx는 운영 공용 +`HMM_MCP_BEARER_TOKEN` 한 개만 비교하므로, 직원 토큰에서 `Bearer ` 문자열을 제거해도 HTTP 401이 +정상이다. + +직원 토큰용 주소는 `https://hmm-backoffice.cloud-handson.com/mcp`다. 조사 시 이 경로는 MCP 도구를 +제공했지만 Authorization 값을 실제로 검증하지 않고 버리는 결함도 확인됐다. + +## 수정 + +- 모든 MCP method에서 `HMM_ACCESS_BEARER_TOKENS`의 SHA-256 해시, 만료, 회수, 직원 재직 상태 검증 +- 누락·무효·만료·회수 토큰은 HTTP 401 +- Tool 호출 connection에서 `HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER` 실행 +- 같은 connection에서 `DBMS_CLOUD_AI_AGENT.RUN_TOOL` 실행 +- 성공·실패와 무관하게 `finally`에서 `HMM_ACCESS_CTX_PKG.CLEAR_USER` +- Agent Factory 표시 주소를 백오피스 MCP 주소로 수정 +- 공용 gateway와 직원 VPD endpoint의 주소·토큰 조합을 운영 문서에서 분리 + +토큰 원문은 Controller에서 `Bearer ` 접두어만 제거해 전달하며 로그·응답·DB에 기록하지 않는다. +DB에는 기존 SHA-256 해시와 식별용 prefix만 남는다. + +## 검증 + +| 항목 | 결과 | +|---|---| +| Maven 자동 테스트 | 107건 통과 | +| 같은 JDBC connection의 context 설정·Tool 실행·context 정리 | 단위 테스트 통과 | +| 운영 JAR SHA-256 | 로컬 검증 빌드와 일치 | +| 서비스 | `vpd-backoffice.service` active | +| 로그인 상태 | HTTP 200 | +| 무토큰 MCP | HTTP 401 | +| 활성 E1002 임시 토큰 initialize | HTTP 200 | +| 활성 E1002 임시 토큰 tools/list | HTTP 200 | +| Tool discovery | HMM Tool 3개 | +| `resolve_hr_term` Tool 호출 | HTTP 200 / MCP result 성공 | +| 회수 후 같은 토큰 | HTTP 401 | + +검증용 토큰은 백오피스의 정상 발급 흐름으로 만들고 원문을 출력하지 않았다. 검증 완료 시 즉시 +회수했으며, 이전 실패 과정에서 남은 같은 용도의 임시 토큰 한 건도 함께 회수했다. + +## Agent Factory 최종 입력 + +| 항목 | 값 | +|---|---| +| Server name | `hmm-backoffice-mcp` | +| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` | +| Authentication mode | `Bearer Token` | +| Bearer token | 백오피스 발급 토큰 원문만 입력 | + +Token 입력란에 `Bearer ` 접두어를 직접 쓰지 않는다. Agent Factory가 HTTP +`Authorization: Bearer ` 헤더를 조립한다. OAuth URL과 client ID/secret은 사용하지 않는다. diff --git a/docs/runbooks/hmm-mcp-token-configuration.md b/docs/runbooks/hmm-mcp-token-configuration.md new file mode 100644 index 0000000..034da2f --- /dev/null +++ b/docs/runbooks/hmm-mcp-token-configuration.md @@ -0,0 +1,140 @@ +# HMM MCP endpoint와 토큰 설정 + +## 인증 수단 구분 + +HMM 포털 로그인과 MCP 호출은 서로 다른 인증 수단을 사용한다. + +| 인증 수단 | 사용 위치 | 전달 방식 | MCP 호출 사용 여부 | +|---|---|---|---| +| `__Host-HMM_PORTAL_SESSION` | `hmm.cloud-handson.com` 포털 로그인 | 브라우저 `Secure; HttpOnly` 쿠키 | 사용 금지 | +| `HMM_MCP_BEARER_TOKEN` | 별도 호환 MCP 서버 접근 | HTTP `Authorization: Bearer` | 포털의 공용 gateway에 사용 | +| HMM 직원별 VPD 토큰 | 백오피스 MCP 사용자 인증·DB context | HTTP `Authorization: Bearer` | Agent Factory 사용자별 연동에 사용 | + +포털 쿠키를 복사해 MCP Bearer Token으로 사용하면 안 된다. 브라우저 JavaScript에서도 포털 쿠키를 +읽을 수 없도록 `HttpOnly`로 설정한다. + +## MCP endpoint 구분 + +두 주소는 같은 토큰을 받지 않는다. + +| 항목 | 값 | +|---|---| +| 사용자별 VPD MCP | `https://hmm-backoffice.cloud-handson.com/mcp` | +| 사용자별 인증 | 백오피스에서 발급한 `vpd_live_*` 토큰 원문 | +| 공용 호환 MCP | `https://hmm-mcp.cloud-handson.com/mcp` | +| 공용 인증 | 운영 `HMM_MCP_BEARER_TOKEN` | +| Transport | Streamable HTTP POST | + +허용 도구는 다음 세 개다. + +| 도구 | 주요 인자 | 용도 | +|---|---|---| +| `search_hr_data` | `query` | 조직, 직원, 휴가 잔여·신청, 근태 조회 | +| `resolve_hr_term` | `term` | 휴가·근태 표현을 표준 용어와 코드로 변환 | +| `search_hr_policy` | `query` | HR 규정 PDF 지식 검색 | + +## 포털의 현재 공용 토큰 조합 + +`config/vpd_token_presets.json`의 현재 조합은 다음과 같다. + +| 데모 사용자 | 역할 | `mcp_token_env` | +|---|---|---| +| E1001 Kim Minseo | HR Team Manager | `HMM_MCP_BEARER_TOKEN` | +| E1002 Lee Jiwon | HR Operations Specialist | `HMM_MCP_BEARER_TOKEN` | +| E1003 Park Dohyun | People Analytics Analyst | `HMM_MCP_BEARER_TOKEN` | +| E1005 Han Seojun | Recruiting Specialist | `HMM_MCP_BEARER_TOKEN` | +| E1007 Kang Minho | HR Coordinator | `HMM_MCP_BEARER_TOKEN` | + +현재는 모든 preset이 같은 서버 관리 토큰을 쓴다. preset을 바꾸면 질문에 포함되는 데모 사용자 +문맥은 바뀌지만, Bearer Token 자체는 바뀌지 않는다. 따라서 이 조합만으로는 사용자별 VPD +보안 경계를 만들지 못한다. + +## 포털 설정 + +`config/mcp_servers.json`에는 토큰 원문 대신 환경변수 이름만 기록한다. + +```json +{ + "id": "hmm_hr_mcp", + "endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp", + "auth_token_env": "HMM_MCP_BEARER_TOKEN", + "tool_allowlist": [ + "search_hr_data", + "resolve_hr_term", + "search_hr_policy" + ] +} +``` + +운영 서버 `/opt/hmm-poc4/.env`에 실제 값이 있어야 한다. + +```dotenv +HMM_MCP_BEARER_TOKEN= +POC3_MCP_TIMEOUT_SECONDS=45 +``` + +토큰 원문은 JSON, Git, 대화 기록, 화면 상세에 저장하지 않는다. 환경 파일은 운영 계정만 읽을 +수 있게 제한한다. + +## 사용자 VPD MCP 직접 호출 예시 + +```bash +export HMM_USER_BEARER_TOKEN='<백오피스 발급 화면에서 한 번 표시된 원문>' + +curl --fail-with-body \ + -H "Authorization: Bearer ${HMM_USER_BEARER_TOKEN}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + --data '{ + "jsonrpc": "2.0", + "id": "tools-list-1", + "method": "tools/list", + "params": {} + }' \ + https://hmm-backoffice.cloud-handson.com/mcp + +unset HMM_USER_BEARER_TOKEN +``` + +MCP 서버가 `initialize`와 session ID를 요구하면 다음 순서를 사용한다. + +1. `initialize` +2. 응답의 `Mcp-Session-Id` 보관 +3. `notifications/initialized` +4. 같은 session header와 Bearer Token으로 `tools/list` +5. 같은 session header와 Bearer Token으로 `tools/call` + +## Oracle AI Database Private Agent Factory 설정 + +| 입력 항목 | 값 | +|---|---| +| Server name | `hmm-backoffice-mcp` | +| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` | +| Authentication mode | Bearer Token | +| Token | 백오피스에서 해당 직원에게 발급한 토큰 원문. `Bearer ` 문자열은 붙이지 않음 | +| Allowed tools | 위 세 도구만 선택 | +| Timeout | 45초부터 시작 | + +이 endpoint는 OAuth authorization endpoint가 아니다. OAuth client ID, client secret, +authorization URL, token URL은 입력하지 않는다. + +## 사용자별 VPD 적용 구조 + +MCP의 표준 `Authorization` header는 하나이므로 “공통 gateway token + 직원 VPD token” 두 개를 +같은 header에 조합하지 않는다. 다음 구조가 권장된다. + +1. 백오피스에서 E1001, E1002 등 직원별 opaque token을 각각 발급한다. +2. MCP 서버는 그 직원 token 자체를 Bearer Token으로 검증한다. +3. 검증된 같은 token으로 DB 연결에서 + `ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(:token)`을 호출한다. +4. 같은 DB 세션에서 `HMM_LEAVE_BALANCES`, `HMM_LEAVE_REQUESTS`를 조회한다. +5. `finally`에서 context를 초기화하고 connection pool에 반환한다. +6. 서버 관리용 공통 토큰과 직원별 토큰을 동시에 요구해야 한다면 OAuth/API Gateway에서 + application identity와 user subject를 하나의 검증 가능한 access token으로 합친다. + +이 흐름은 2026-07-23 백오피스 MCP에 적용됐다. 모든 MCP method가 토큰 해시·만료·회수·재직 +상태를 확인하며, Tool 호출은 같은 JDBC connection에서 `HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER` +후 실행하고 `finally`에서 context를 지운다. 무토큰·무효·회수 토큰은 HTTP 401이다. + +포털의 `HMM_MCP_BEARER_TOKEN` 공용 preset은 별도 호환 gateway를 사용하는 기존 UI 라우팅이다. +Agent Factory의 사용자별 권한 검증에는 반드시 백오피스 MCP 주소와 직원 토큰을 사용한다. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java index 5acc154..81f6c80 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java @@ -7,5 +7,5 @@ import com.fasterxml.jackson.databind.node.ObjectNode; @FunctionalInterface public interface HmmAiAgentToolRunner { - JsonNode run(String toolName, ObjectNode input); + JsonNode run(String toolName, ObjectNode input, String bearerToken); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpBearerAuthenticator.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpBearerAuthenticator.java new file mode 100644 index 0000000..5fc7139 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpBearerAuthenticator.java @@ -0,0 +1,8 @@ +package com.cloudhandson.vpdbackoffice.service; + +/** Validates an HMM backoffice bearer token without retaining its plaintext value. */ +@FunctionalInterface +public interface HmmMcpBearerAuthenticator { + + HmmMcpPrincipal authenticate(String bearerToken); +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpPrincipal.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpPrincipal.java new file mode 100644 index 0000000..75b9843 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpPrincipal.java @@ -0,0 +1,9 @@ +package com.cloudhandson.vpdbackoffice.service; + +/** Authenticated HMM employee identity resolved from a one-way bearer-token hash. */ +public record HmmMcpPrincipal( + long employeeId, + String employeeCode, + Long teamId +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java index 8a8264f..cb53f17 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java @@ -3,7 +3,11 @@ package com.cloudhandson.vpdbackoffice.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.TextNode; +import java.sql.CallableStatement; import java.sql.Clob; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import org.springframework.jdbc.core.ConnectionCallback; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -20,16 +24,45 @@ public class JdbcHmmAiAgentToolRunner implements HmmAiAgentToolRunner { } @Override - public JsonNode run(String toolName, com.fasterxml.jackson.databind.node.ObjectNode input) { + public JsonNode run( + String toolName, + com.fasterxml.jackson.databind.node.ObjectNode input, + String bearerToken + ) { try { String request = objectMapper.writeValueAsString(input); - String response = jdbcTemplate.queryForObject(""" - SELECT DBMS_CLOUD_AI_AGENT.RUN_TOOL(?, TO_CLOB(?)) - FROM dual - """, (resultSet, rowNum) -> { - Clob clob = resultSet.getClob(1); - return clob == null ? "" : clob.getSubString(1, (int) clob.length()); - }, toolName, request); + String response = jdbcTemplate.execute((ConnectionCallback) connection -> { + boolean contextSet = false; + try { + try (CallableStatement statement = connection.prepareCall( + "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;")) { + statement.setString(1, bearerToken); + statement.execute(); + contextSet = true; + } + try (PreparedStatement statement = connection.prepareStatement(""" + SELECT DBMS_CLOUD_AI_AGENT.RUN_TOOL(?, TO_CLOB(?)) + FROM dual + """)) { + statement.setString(1, toolName); + statement.setString(2, request); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return ""; + } + Clob clob = resultSet.getClob(1); + return clob == null ? "" : clob.getSubString(1, (int) clob.length()); + } + } + } finally { + if (contextSet) { + try (CallableStatement statement = connection.prepareCall( + "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;")) { + statement.execute(); + } + } + } + }); if (response == null || response.isBlank()) { return objectMapper.createObjectNode(); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmMcpBearerAuthenticator.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmMcpBearerAuthenticator.java new file mode 100644 index 0000000..a343091 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmMcpBearerAuthenticator.java @@ -0,0 +1,45 @@ +package com.cloudhandson.vpdbackoffice.service; + +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** Resolves active HMM employee tokens using only their SHA-256 hashes in ADB. */ +@Service +public class JdbcHmmMcpBearerAuthenticator implements HmmMcpBearerAuthenticator { + + private static final int MAX_TOKEN_LENGTH = 4096; + + private final JdbcTemplate jdbcTemplate; + + public JdbcHmmMcpBearerAuthenticator(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public HmmMcpPrincipal authenticate(String bearerToken) { + if (bearerToken == null || bearerToken.isBlank() || bearerToken.length() > MAX_TOKEN_LENGTH) { + throw new McpUnauthorizedException(); + } + try { + return jdbcTemplate.queryForObject(""" + SELECT employee.employee_id, + employee.employee_code, + employee.team_id + FROM hmm_access_bearer_tokens token + JOIN hmm_hr_employees employee + ON employee.employee_id = token.employee_id + WHERE token.key_hash = STANDARD_HASH(?, 'SHA256') + AND token.revoked_at IS NULL + AND token.expires_at > CAST(SYSTIMESTAMP AS TIMESTAMP) + AND employee.employment_status = 'ACTIVE' + """, (resultSet, rowNum) -> new HmmMcpPrincipal( + resultSet.getLong("employee_id"), + resultSet.getString("employee_code"), + resultSet.getObject("team_id", Long.class) + ), bearerToken); + } catch (EmptyResultDataAccessException exception) { + throw new McpUnauthorizedException(); + } + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 0645b4e..2330ce8 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -37,10 +37,16 @@ public class McpSseService { ); private final HmmAiAgentToolRunner agentToolRunner; + private final HmmMcpBearerAuthenticator bearerAuthenticator; private final ObjectMapper objectMapper; - public McpSseService(HmmAiAgentToolRunner agentToolRunner, ObjectMapper objectMapper) { + public McpSseService( + HmmAiAgentToolRunner agentToolRunner, + HmmMcpBearerAuthenticator bearerAuthenticator, + ObjectMapper objectMapper + ) { this.agentToolRunner = agentToolRunner; + this.bearerAuthenticator = bearerAuthenticator; this.objectMapper = objectMapper; } @@ -48,11 +54,9 @@ public class McpSseService { return handle(contextPath, request, ""); } - /** - * Backoffice authentication protects this compatibility endpoint. The public - * HMM MCP endpoint performs its own fixed Bearer-token validation in Nginx. - */ - public ObjectNode handle(String contextPath, JsonNode request, String ignoredAuthorization) { + /** Validates the user bearer before serving discovery or executing a tool. */ + public ObjectNode handle(String contextPath, JsonNode request, String bearerToken) { + bearerAuthenticator.authenticate(bearerToken); ObjectNode response = objectMapper.createObjectNode(); response.put("jsonrpc", "2.0"); if (request != null && request.has("id")) { @@ -66,7 +70,7 @@ public class McpSseService { case "initialize" -> initializeResult(contextPath); case "notifications/initialized" -> objectMapper.createObjectNode(); case "tools/list" -> toolsListResult(); - case "tools/call" -> toolsCallResult(parameters); + case "tools/call" -> toolsCallResult(parameters, bearerToken); default -> throw new AppException("지원하지 않는 MCP method입니다: " + method); }); } catch (Exception exception) { @@ -128,7 +132,7 @@ public class McpSseService { return item; } - private ObjectNode toolsCallResult(JsonNode params) { + private ObjectNode toolsCallResult(JsonNode params, String bearerToken) { String requestedName = params.path("name").asText(""); ToolSpec tool = HMM_TOOLS.stream() .filter(candidate -> candidate.name().equals(requestedName)) @@ -140,7 +144,7 @@ public class McpSseService { } ObjectNode input = objectMapper.createObjectNode(); input.put(tool.agentParameterName(), argument); - JsonNode toolResponse = agentToolRunner.run(tool.agentToolName(), input); + JsonNode toolResponse = agentToolRunner.run(tool.agentToolName(), input, bearerToken); ObjectNode payload = objectMapper.createObjectNode(); payload.put("toolName", tool.name()); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpUnauthorizedException.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpUnauthorizedException.java new file mode 100644 index 0000000..f60b453 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.cloudhandson.vpdbackoffice.service; + +/** Deliberately generic MCP authentication failure; never include token material. */ +public class McpUnauthorizedException extends RuntimeException { + + public McpUnauthorizedException() { + super("유효한 HMM 사용자 Bearer Token이 필요합니다."); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java index e78738d..6d9b078 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java @@ -1,6 +1,7 @@ package com.cloudhandson.vpdbackoffice.web; import com.cloudhandson.vpdbackoffice.service.McpSseService; +import com.cloudhandson.vpdbackoffice.service.McpUnauthorizedException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; @@ -40,12 +41,18 @@ public class McpSseController { @RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization, @RequestBody JsonNode request ) { - // JSON-RPC notifications never receive a response body. Current MCP - // clients send notifications/initialized immediately after initialize. - if (request != null && !request.has("id")) { - return ResponseEntity.accepted().build(); + try { + ObjectNode response = mcpSseService.handle( + "default", request, bearerToken(authorization)); + // JSON-RPC notifications never receive a response body. Current MCP + // clients send notifications/initialized immediately after initialize. + if (request != null && !request.has("id")) { + return ResponseEntity.accepted().build(); + } + return ResponseEntity.ok(response); + } catch (McpUnauthorizedException exception) { + return ResponseEntity.status(401).build(); } - return ResponseEntity.ok(mcpSseService.handle("default", request, bearerToken(authorization))); } @GetMapping(path = "/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @@ -98,8 +105,13 @@ public class McpSseController { JsonNode request ) throws IOException { String normalizedContextPath = normalizeContextPath(contextPath); - ObjectNode response = mcpSseService.handle( - normalizedContextPath, request, bearerToken(authorization)); + ObjectNode response; + try { + response = mcpSseService.handle( + normalizedContextPath, request, bearerToken(authorization)); + } catch (McpUnauthorizedException exception) { + return ResponseEntity.status(401).build(); + } if (sessionId == null || sessionId.isBlank()) { return ResponseEntity.ok(response); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index eaec76e..5769bf1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -62,7 +62,7 @@ backoffice: username: ${BACKOFFICE_ORDS_DB_USERNAME:} password: ${BACKOFFICE_ORDS_DB_PASSWORD:} mcp: - public-url: ${BACKOFFICE_HMM_MCP_PUBLIC_URL:https://hmm-mcp.cloud-handson.com/mcp} + public-url: ${BACKOFFICE_HMM_MCP_PUBLIC_URL:https://hmm-backoffice.cloud-handson.com/mcp} ai: enabled: ${BACKOFFICE_AI_ENABLED:false} provider: ${BACKOFFICE_AI_PROVIDER:openai} diff --git a/src/main/resources/templates/mcp-sse.html b/src/main/resources/templates/mcp-sse.html index ea4c659..a0bf0c7 100644 --- a/src/main/resources/templates/mcp-sse.html +++ b/src/main/resources/templates/mcp-sse.html @@ -22,7 +22,7 @@
공개 MCP Endpoint - https://hmm-mcp.cloud-handson.com/mcp + https://hmm-backoffice.cloud-handson.com/mcp Private Agent Factory와 외부 MCP client가 사용하는 Streamable HTTP Endpoint입니다.
@@ -47,7 +47,7 @@ HMM MCP - https://hmm-mcp.cloud-handson.com/mcp + https://hmm-backoffice.cloud-handson.com/mcp Transport diff --git a/src/main/resources/templates/settings.html b/src/main/resources/templates/settings.html index 5fa9230..9cdd944 100644 --- a/src/main/resources/templates/settings.html +++ b/src/main/resources/templates/settings.html @@ -31,7 +31,7 @@ Public MCP endpoint -

Agent Factory 등록 주소: https://hmm-mcp.cloud-handson.com/mcp

+

Agent Factory 등록 주소: https://hmm-backoffice.cloud-handson.com/mcp

diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunnerTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunnerTest.java new file mode 100644 index 0000000..1975660 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunnerTest.java @@ -0,0 +1,65 @@ +package com.cloudhandson.vpdbackoffice.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.sql.CallableStatement; +import java.sql.Clob; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.jdbc.core.JdbcTemplate; + +class JdbcHmmAiAgentToolRunnerTest { + + @Test + void setsAndClearsHMMContextOnTheSameConnection() throws Exception { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + Connection connection = mock(Connection.class); + CallableStatement setContext = mock(CallableStatement.class); + CallableStatement clearContext = mock(CallableStatement.class); + PreparedStatement runTool = mock(PreparedStatement.class); + ResultSet resultSet = mock(ResultSet.class); + Clob clob = mock(Clob.class); + + when(jdbcTemplate.execute(any(ConnectionCallback.class))).thenAnswer(invocation -> { + ConnectionCallback callback = invocation.getArgument(0); + return callback.doInConnection(connection); + }); + when(connection.prepareCall( + "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;")) + .thenReturn(setContext); + when(connection.prepareCall("BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;")) + .thenReturn(clearContext); + when(connection.prepareStatement(any(String.class))).thenReturn(runTool); + when(runTool.executeQuery()).thenReturn(resultSet); + when(resultSet.next()).thenReturn(true); + when(resultSet.getClob(1)).thenReturn(clob); + when(clob.length()).thenReturn(15L); + when(clob.getSubString(1, 15)).thenReturn("{\"status\":\"ok\"}"); + + var runner = new JdbcHmmAiAgentToolRunner(jdbcTemplate, new ObjectMapper()); + var result = runner.run( + "HMM_HR_TERM_RESOLVER", + new ObjectMapper().createObjectNode().put("P_TERM", "annual leave"), + "opaque-user-token"); + + assertThat(result.path("status").asText()).isEqualTo("ok"); + InOrder order = inOrder(connection, setContext, runTool, clearContext); + order.verify(connection).prepareCall( + "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;"); + order.verify(setContext).setString(1, "opaque-user-token"); + order.verify(setContext).execute(); + order.verify(connection).prepareStatement(any(String.class)); + order.verify(runTool).executeQuery(); + order.verify(connection).prepareCall("BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;"); + order.verify(clearContext).execute(); + } +} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index bb7bc1d..328c347 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -1,6 +1,7 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -11,11 +12,14 @@ class McpSseServiceTest { private final ObjectMapper objectMapper = new ObjectMapper(); private final CapturingHmmAiAgentToolRunner agentToolRunner = new CapturingHmmAiAgentToolRunner(); - private final McpSseService service = new McpSseService(agentToolRunner, objectMapper); + private final HmmMcpBearerAuthenticator bearerAuthenticator = + token -> new HmmMcpPrincipal(1L, "E1001", 1L); + private final McpSseService service = + new McpSseService(agentToolRunner, bearerAuthenticator, objectMapper); @Test void listsHMMTermDataAndPolicyToolsWithTheirActualInputs() { - ObjectNode response = service.handle("default", request(1, "tools/list")); + ObjectNode response = service.handle("default", request(1, "tools/list"), "valid-token"); var tools = response.path("result").path("tools"); assertThat(tools).hasSize(3); @@ -39,10 +43,11 @@ class McpSseServiceTest { params.put("name", "resolve_hr_term"); params.putObject("arguments").put("term", "연차 이월"); - ObjectNode response = service.handle("default", request, "ignored-by-backoffice-session"); + ObjectNode response = service.handle("default", request, "valid-token"); assertThat(agentToolRunner.toolName).isEqualTo("HMM_HR_TERM_RESOLVER"); assertThat(agentToolRunner.input.path("P_TERM").asText()).isEqualTo("연차 이월"); + assertThat(agentToolRunner.bearerToken).isEqualTo("valid-token"); assertThat(response.path("error").isMissingNode()).isTrue(); assertThat(response.path("result").path("isError").asBoolean()).isFalse(); assertThat(response.path("result").path("content").get(0).path("text").asText()) @@ -51,6 +56,20 @@ class McpSseServiceTest { .contains("ANNUAL_LEAVE_CARRYOVER"); } + @Test + void rejectsDiscoveryWhenBearerAuthenticationFails() { + McpSseService rejectingService = new McpSseService( + agentToolRunner, + token -> { + throw new McpUnauthorizedException(); + }, + objectMapper); + + assertThatThrownBy(() -> + rejectingService.handle("default", request(4, "tools/list"), "invalid-token")) + .isInstanceOf(McpUnauthorizedException.class); + } + @Test void rejectsUnknownToolsWithoutCallingTheAgentRunner() { ObjectNode request = request(3, "tools/call"); @@ -58,7 +77,7 @@ class McpSseServiceTest { params.put("name", "ords.query.kb_select_ai_vpd"); params.putObject("arguments").put("prompt", "legacy query"); - ObjectNode response = service.handle("default", request); + ObjectNode response = service.handle("default", request, "valid-token"); assertThat(response.path("result").isMissingNode()).isTrue(); assertThat(response.path("error").path("message").asText()).contains("등록되지 않은 HMM MCP tool"); @@ -76,11 +95,17 @@ class McpSseServiceTest { private String toolName; private ObjectNode input; + private String bearerToken; @Override - public JsonNode run(String requestedToolName, ObjectNode requestedInput) { + public JsonNode run( + String requestedToolName, + ObjectNode requestedInput, + String bearerToken + ) { toolName = requestedToolName; input = requestedInput.deepCopy(); + this.bearerToken = bearerToken; return objectMapper.createObjectNode() .put("termCode", "ANNUAL_LEAVE_CARRYOVER") .put("termName", "연차 이월"); diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java index 6eac9da..62fde34 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java @@ -24,14 +24,14 @@ class SettingsTemplateRenderTest { var context = new Context(Locale.KOREAN); context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token")); context.setVariable("ordsBaseUrl", "https://ords.example.test/ords"); - context.setVariable("hmmMcpPublicUrl", "https://hmm-mcp.cloud-handson.com/mcp"); + context.setVariable("hmmMcpPublicUrl", "https://hmm-backoffice.cloud-handson.com/mcp"); String connection = engine.process("settings", context); String database = engine.process("settings-database", context); assertThat(connection) .contains("HMM HR Agent 도구") - .contains("https://hmm-mcp.cloud-handson.com/mcp") + .contains("https://hmm-backoffice.cloud-handson.com/mcp") .contains("Legacy ORDS Base URL") .contains("/settings/database") .doesNotContain("g329127dfd380ad-kbaipoc")