refs #712: authenticate backoffice MCP user tokens
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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<String>) 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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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이 필요합니다.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user