refs #744: add AD OIDC MCP identity bridge
This commit is contained in:
@@ -42,6 +42,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-oauth2-jose</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.oracle.database.jdbc</groupId>
|
||||
<artifactId>ojdbc11</artifactId>
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.cloudhandson.ddsbackoffice;
|
||||
|
||||
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
||||
import com.cloudhandson.ddsbackoffice.config.DdsMcpIamProperties;
|
||||
import com.cloudhandson.ddsbackoffice.config.DdsMcpOidcProperties;
|
||||
import com.cloudhandson.vpdbackoffice.VpdBackofficeApplication;
|
||||
import com.cloudhandson.vpdbackoffice.web.DashboardController;
|
||||
import com.cloudhandson.vpdbackoffice.web.LoginController;
|
||||
@@ -15,7 +16,7 @@ import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.FilterType;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties({DdsProperties.class, DdsMcpIamProperties.class})
|
||||
@EnableConfigurationProperties({DdsProperties.class, DdsMcpIamProperties.class, DdsMcpOidcProperties.class})
|
||||
@MapperScan("com.cloudhandson.vpdbackoffice.mapper")
|
||||
@ComponentScan(
|
||||
basePackages = {"com.cloudhandson.ddsbackoffice", "com.cloudhandson.vpdbackoffice"},
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.cloudhandson.ddsbackoffice.config;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "dds.mcp.oidc", name = "enabled", havingValue = "true")
|
||||
public class DdsMcpOidcConfiguration {
|
||||
|
||||
@Bean
|
||||
JwtDecoder ddsMcpJwtDecoder(DdsMcpOidcProperties properties) {
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(properties.issuerUri()).build();
|
||||
OAuth2TokenValidator<Jwt> audienceValidator = jwt -> jwt.getAudience().contains(properties.audience())
|
||||
? OAuth2TokenValidatorResult.success()
|
||||
: OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", "Invalid audience", null));
|
||||
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
||||
JwtValidators.createDefaultWithIssuer(properties.issuerUri()), audienceValidator));
|
||||
return decoder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.cloudhandson.ddsbackoffice.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.ConstructorBinding;
|
||||
|
||||
/** OIDC resource-server settings for externally authenticated MCP callers. */
|
||||
@ConfigurationProperties(prefix = "dds.mcp.oidc")
|
||||
public record DdsMcpOidcProperties(boolean enabled, String issuerUri, String audience) {
|
||||
|
||||
@ConstructorBinding
|
||||
public DdsMcpOidcProperties {
|
||||
issuerUri = trim(issuerUri);
|
||||
audience = trim(audience);
|
||||
if (enabled && (issuerUri.isBlank() || audience.isBlank())) {
|
||||
throw new IllegalArgumentException("DDS MCP OIDC issuer-uri와 audience를 설정해야 합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private static String trim(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.cloudhandson.ddsbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationSynchronizer;
|
||||
import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationSynchronizer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataAccessResourceFailureException;
|
||||
@@ -8,7 +8,7 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
/** Republishes all local DDS MCP identities in the same request as an authorization change. */
|
||||
@Component
|
||||
public class DdsMcpAuthorizationChangeListener implements DdsAuthorizationSynchronizer {
|
||||
public class DdsMcpAuthorizationChangeListener implements ExternalAuthorizationSynchronizer {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DdsMcpAuthorizationChangeListener.class);
|
||||
private final DdsMcpEndUserPublisher publisher;
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Revalidates the MCP request bearer before every tool invocation. */
|
||||
@@ -20,22 +21,32 @@ public class DdsMcpBearerAuthenticator {
|
||||
private final BearerTokenService bearerTokenService;
|
||||
private final UserMapper userMapper;
|
||||
private final DdsMcpEndUserResolver endUserResolver;
|
||||
private final ObjectProvider<DdsMcpOidcAuthenticator> oidcAuthenticator;
|
||||
private final Clock clock;
|
||||
|
||||
public DdsMcpBearerAuthenticator(
|
||||
BearerTokenService bearerTokenService,
|
||||
UserMapper userMapper,
|
||||
DdsMcpEndUserResolver endUserResolver,
|
||||
ObjectProvider<DdsMcpOidcAuthenticator> oidcAuthenticator,
|
||||
Clock clock
|
||||
) {
|
||||
this.bearerTokenService = bearerTokenService;
|
||||
this.userMapper = userMapper;
|
||||
this.endUserResolver = endUserResolver;
|
||||
this.oidcAuthenticator = oidcAuthenticator;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public DdsMcpAuthenticatedUser authenticate(String authorization) {
|
||||
String plainToken = bearerValue(authorization);
|
||||
if (plainToken.chars().filter(character -> character == '.').count() == 2) {
|
||||
DdsMcpOidcAuthenticator oidc = oidcAuthenticator.getIfAvailable();
|
||||
if (oidc == null) {
|
||||
throw denied();
|
||||
}
|
||||
return oidc.authenticate(plainToken);
|
||||
}
|
||||
BearerTokenRecord token = bearerTokenService.findByPlainToken(plainToken);
|
||||
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
|
||||
if (token == null || !token.active(now) || !bearerTokenService.matches(token, plainToken)) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.cloudhandson.ddsbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||
import java.util.List;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Maps a verified OIDC issuer and subject to one active HMM application user. */
|
||||
@Service
|
||||
public class DdsMcpExternalIdentityResolver {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public DdsMcpExternalIdentityResolver(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
public long resolveApplicationUserId(String issuer, String subject) {
|
||||
if (!safe(issuer) || !safe(subject)) {
|
||||
throw denied();
|
||||
}
|
||||
try {
|
||||
List<Long> userIds = jdbcTemplate.query("""
|
||||
SELECT b.application_user_id
|
||||
FROM cb_external_identity_binding b
|
||||
JOIN cb_app_user u ON u.user_id = b.application_user_id
|
||||
WHERE b.issuer = ?
|
||||
AND b.subject = ?
|
||||
AND b.active = 'Y'
|
||||
AND u.active = 'Y'
|
||||
""", (row, ignored) -> row.getLong("application_user_id"), issuer, subject);
|
||||
if (userIds.size() != 1) {
|
||||
throw denied();
|
||||
}
|
||||
return userIds.getFirst();
|
||||
} catch (AppException exception) {
|
||||
throw exception;
|
||||
} catch (DataAccessException exception) {
|
||||
throw new AppException("DDS_CONTEXT_UNAVAILABLE: 외부 ID 매핑을 확인할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean safe(String value) {
|
||||
return value != null && !value.isBlank() && value.length() <= 512;
|
||||
}
|
||||
|
||||
private AppException denied() {
|
||||
return new AppException("AUTHORIZATION_DENIED: 외부 OIDC 사용자를 DDS에 매핑할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.cloudhandson.ddsbackoffice.service;
|
||||
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsMcpAuthenticatedUser;
|
||||
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Validates a signed OIDC JWT before looking up the DDS identity bridge. */
|
||||
@Service
|
||||
@ConditionalOnProperty(prefix = "dds.mcp.oidc", name = "enabled", havingValue = "true")
|
||||
public class DdsMcpOidcAuthenticator {
|
||||
|
||||
private final JwtDecoder jwtDecoder;
|
||||
private final DdsMcpExternalIdentityResolver externalIdentityResolver;
|
||||
private final UserMapper userMapper;
|
||||
private final DdsMcpEndUserResolver endUserResolver;
|
||||
|
||||
public DdsMcpOidcAuthenticator(
|
||||
JwtDecoder jwtDecoder,
|
||||
DdsMcpExternalIdentityResolver externalIdentityResolver,
|
||||
UserMapper userMapper,
|
||||
DdsMcpEndUserResolver endUserResolver
|
||||
) {
|
||||
this.jwtDecoder = jwtDecoder;
|
||||
this.externalIdentityResolver = externalIdentityResolver;
|
||||
this.userMapper = userMapper;
|
||||
this.endUserResolver = endUserResolver;
|
||||
}
|
||||
|
||||
public DdsMcpAuthenticatedUser authenticate(String token) {
|
||||
try {
|
||||
Jwt jwt = jwtDecoder.decode(token);
|
||||
long userId = externalIdentityResolver.resolveApplicationUserId(
|
||||
jwt.getIssuer() == null ? null : jwt.getIssuer().toString(), jwt.getSubject());
|
||||
AppUser user = userMapper.findById(userId);
|
||||
if (user == null || !user.active()) {
|
||||
throw denied();
|
||||
}
|
||||
return new DdsMcpAuthenticatedUser(user.userId(), user.username(), endUserResolver.resolve(user.userId()));
|
||||
} catch (AppException exception) {
|
||||
throw exception;
|
||||
} catch (JwtException | IllegalArgumentException exception) {
|
||||
throw denied();
|
||||
}
|
||||
}
|
||||
|
||||
private AppException denied() {
|
||||
return new AppException("AUTHORIZATION_DENIED: MCP OIDC 토큰을 확인할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,12 @@ backoffice:
|
||||
dds:
|
||||
mcp:
|
||||
permission-sync-enabled: true
|
||||
oidc:
|
||||
# Disabled unless an external OIDC issuer is explicitly configured.
|
||||
# The production issuer must use HTTPS; the isolated AD VM uses a temporary HTTP value only for PoC.
|
||||
enabled: ${DDS_MCP_OIDC_ENABLED:false}
|
||||
issuer-uri: ${DDS_MCP_OIDC_ISSUER_URI:}
|
||||
audience: ${DDS_MCP_OIDC_AUDIENCE:dds-mcp}
|
||||
iam:
|
||||
domain-url: ${DDS_OCI_IAM_DOMAIN_URL:}
|
||||
token-uri: ${DDS_OCI_IAM_TOKEN_URI:}
|
||||
|
||||
@@ -3,8 +3,8 @@ package com.cloudhandson.ddsbackoffice.service;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationChangeNotifier;
|
||||
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationSynchronizer;
|
||||
import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationChangeNotifier;
|
||||
import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationSynchronizer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
|
||||
|
||||
@@ -16,9 +16,9 @@ class DdsMcpAuthorizationChangeListenerTest {
|
||||
when(publisher.publish()).thenReturn(new com.cloudhandson.ddsbackoffice.domain.DdsMcpEndUserPublishResult(3, 0, 0));
|
||||
DdsMcpAuthorizationChangeListener listener = new DdsMcpAuthorizationChangeListener(publisher);
|
||||
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
|
||||
factory.registerSingleton("ddsSynchronizer", (DdsAuthorizationSynchronizer) listener);
|
||||
factory.registerSingleton("ddsSynchronizer", (ExternalAuthorizationSynchronizer) listener);
|
||||
|
||||
new DdsAuthorizationChangeNotifier(factory.getBeanProvider(DdsAuthorizationSynchronizer.class))
|
||||
new ExternalAuthorizationChangeNotifier(factory.getBeanProvider(ExternalAuthorizationSynchronizer.class))
|
||||
.changed("PERMISSION_SAVED");
|
||||
|
||||
verify(publisher).publish();
|
||||
|
||||
Reference in New Issue
Block a user