refs #712: authenticate backoffice MCP user tokens

This commit is contained in:
devmrko
2026-07-23 14:45:18 +09:00
parent ce4b418603
commit 1917df09a2
18 changed files with 525 additions and 37 deletions

View File

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

View File

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

View File

@@ -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
) {
}

View File

@@ -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();
}

View File

@@ -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();
}
}
}

View File

@@ -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());

View File

@@ -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이 필요합니다.");
}
}

View File

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