diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/AuthEndpointConfig.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/AuthEndpointConfig.java index 6350b7fb6a..ff8eb75796 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/AuthEndpointConfig.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/AuthEndpointConfig.java @@ -145,7 +145,8 @@ RouterFunction routes() { .andRoute(path("/gateway/api/v1/auth/keys/public/current"), resendTo("/api/v1/auth/keys/public/current")) .andRoute(path("/gateway/api/v1/auth/oidc-token/validate"), resendTo("/api/v1/auth/oidc-token/validate")) .andRoute(path("/gateway/api/v1/auth/oidc/webfinger"), resendTo("/api/v1/auth/oidc/webfinger")) - .andRoute(path("/gateway/auth/check"), resendTo("/auth/check")); + .andRoute(path("/gateway/auth/check"), resendTo("/auth/check")) + .andRoute(path("/gateway/api/v1/auth/delegate/passticket"), resendTo("/api/v1/auth/delegate/passticket")); } } diff --git a/zaas-service/src/main/java/org/zowe/apiml/zaas/controllers/SecurityTokenServiceController.java b/zaas-service/src/main/java/org/zowe/apiml/zaas/controllers/SecurityTokenServiceController.java new file mode 100644 index 0000000000..7344a893d4 --- /dev/null +++ b/zaas-service/src/main/java/org/zowe/apiml/zaas/controllers/SecurityTokenServiceController.java @@ -0,0 +1,97 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.zaas.controllers; + +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.util.Strings; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.zowe.apiml.passticket.PassTicketException; +import org.zowe.apiml.passticket.PassTicketService; +import org.zowe.apiml.zaas.security.mapping.NativeMapperWrapper; +import org.zowe.commons.usermap.MapperResponse; + +import io.swagger.v3.oas.annotations.Hidden; +import lombok.Builder; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Controller offer method to control security. It can contain method for user + * and also method for calling services + * by gateway to distribute state of authentication between nodes. + */ +@RequiredArgsConstructor +@RestController +@RequestMapping(SecurityTokenServiceController.CONTROLLER_PATH) +@Slf4j +public class SecurityTokenServiceController { + + @Value("${apiml.security.oidc.registry:}") + protected String registry; + + private final PassTicketService passTicketService; + private final NativeMapperWrapper nativeMapper; + + public static final String CONTROLLER_PATH = "/zaas/api/v1/auth/delegate"; + public static final String PASSTICKET_PATH = "/passticket"; + + @PostMapping(value = SecurityTokenServiceController.PASSTICKET_PATH, produces = MediaType.APPLICATION_JSON_VALUE) + @ConditionalOnProperty(value = "apiml.security.delegatePassticket.enabled", havingValue = "true", matchIfMissing = false) + @PreAuthorize("@safMethodSecurityExpressionRoot.hasSafServiceResourceAccess('DELEGATE.PASSTICKET', 'READ',#root)") + @Hidden + public ResponseEntity getPassTicket(@RequestBody PassTicketRequest passticketRequest) + throws Exception { + String applID = passticketRequest.getApplId(); + String emailID = passticketRequest.getEmailId(); + String zosUserId = ""; + + if (Strings.isBlank(emailID) || Strings.isBlank(applID)) { + log.debug("getPassTicket: Invalid applId or EmailId"); + return ResponseEntity.badRequest().build(); + } + try { + MapperResponse response = nativeMapper.getUserIDForDN(emailID, registry); + if (response.getRc() == 0 && StringUtils.isNotEmpty(response.getUserId())) { + zosUserId = response.getUserId(); + } + log.debug("getPassTicket: Processing request for ZOS userId: {}", zosUserId); + var ticket = passTicketService.generate(zosUserId, applID); + log.debug("getPassTicket: Request processed with emailId: {} and ZOS userId: {}", emailID, zosUserId); + return ResponseEntity.ok(new PassTicketResponse(ticket, zosUserId)); + } catch (PassTicketException ex) { + log.error("getPassTicket: Failed to generate passticket", ex); + return ResponseEntity.internalServerError().build(); + } + } + + @Data + public static class PassTicketRequest { + private String emailId; + private String applId; + } + + @Data + @Builder + public static class PassTicketResponse { + private String passticket; + private String tsoUserid; + } + +} \ No newline at end of file diff --git a/zaas-service/src/main/java/org/zowe/apiml/zaas/security/config/NewSecurityConfiguration.java b/zaas-service/src/main/java/org/zowe/apiml/zaas/security/config/NewSecurityConfiguration.java index d1cef3c6c8..a2bda5ab8c 100644 --- a/zaas-service/src/main/java/org/zowe/apiml/zaas/security/config/NewSecurityConfiguration.java +++ b/zaas-service/src/main/java/org/zowe/apiml/zaas/security/config/NewSecurityConfiguration.java @@ -343,6 +343,53 @@ SecurityFilterChain authZaasEndpointsFilterChain(HttpSecurity http) throws Excep } + + /** + * Secures endpoints: + * - /auth/delegate/passticket + * + * Requires authentication by a client certificate forwarded form Gateway or basic authentication, supports only credentials in header. + * Order of custom filters: + * - CategorizeCertsFilter - checks for forwarded client certificate and put it into a custom request attribute + * - X509AuthAwareFilter - attempts to log in using a user using forwarded client certificate, replaces pre-authentication in security context by the authentication result + */ + @Configuration + @RequiredArgsConstructor + @Order(10) + class DelegatePassticketProtectedEndpoints { + + private final CompoundAuthProvider compoundAuthProvider; + + @Bean + SecurityFilterChain passticketProtectedEndpointsFilterChain(HttpSecurity http) throws Exception { + baseConfigure(http.securityMatchers(matchers -> matchers.requestMatchers( // no http method to catch all attempts to login and handle them here. Otherwise it falls to default filterchain and tries to route the calls, which doesnt make sense + "/zaas/api/v1/auth/delegate/passticket" + ))) + .authorizeHttpRequests(requests -> requests + .anyRequest().authenticated()) + .authenticationProvider(compoundAuthProvider) // for authenticating credentials + .with(new CustomSecurityFilters(), Customizer.withDefaults()); + return http.build(); + } + + private class CustomSecurityFilters extends AbstractHttpConfigurer { + @Override + public void configure(HttpSecurity http) { + http.addFilterAfter(new CategorizeCertsFilter(publicKeyCertificatesBase64, certificateValidator), org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter.class) + .addFilterAfter(x509ForwardingAwareAuthenticationFilter(), CategorizeCertsFilter.class); + } + + private X509ForwardingAwareAuthenticationFilter x509ForwardingAwareAuthenticationFilter() { + return new X509AuthAwareFilter("/**", + handlerInitializer.getAuthenticationFailureHandler(), + x509AuthenticationProvider); + } + + } + + } + + /** * Query and Ticket and Refresh endpoints share single filter that handles auth with and without certificate. This logic is encapsulated in the queryFilter or ticketFilter. * Query endpoint does not require certificate to be present in RequestContext. It verifies JWT token. diff --git a/zaas-service/src/main/resources/application.yml b/zaas-service/src/main/resources/application.yml index c65b7233b1..4f39590cd0 100644 --- a/zaas-service/src/main/resources/application.yml +++ b/zaas-service/src/main/resources/application.yml @@ -86,6 +86,8 @@ apiml: urls: authenticate: https://localhost:10013/zss/saf/authenticate verify: https://localhost:10013/zss/saf/verify + delegatePassticket: + enabled: true health: protected: true spring: diff --git a/zaas-service/src/test/java/org/zowe/apiml/zaas/controllers/SecurityTokenServiceControllerTest.java b/zaas-service/src/test/java/org/zowe/apiml/zaas/controllers/SecurityTokenServiceControllerTest.java new file mode 100644 index 0000000000..1cb4d15a7d --- /dev/null +++ b/zaas-service/src/test/java/org/zowe/apiml/zaas/controllers/SecurityTokenServiceControllerTest.java @@ -0,0 +1,150 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.zaas.controllers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import static org.mockito.ArgumentMatchers.anyString; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import org.mockito.MockitoAnnotations; +import org.springframework.http.ResponseEntity; +import org.zowe.apiml.passticket.PassTicketService; +import org.zowe.apiml.zaas.security.mapping.NativeMapperWrapper; +import org.zowe.commons.usermap.MapperResponse; + +class SecurityTokenServiceControllerTest { + + @Mock + private PassTicketService passTicketService; + + @Mock + private NativeMapperWrapper nativeMapper; + + @InjectMocks + private SecurityTokenServiceController SecurityTokenServiceController; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + SecurityTokenServiceController.registry = "testRegistry"; + } + + @Nested + class SuccessfulRequests { + + @Test + void shouldReturnPassTicketWhenRequestIsValid() throws Exception { + SecurityTokenServiceController.PassTicketRequest request = new SecurityTokenServiceController.PassTicketRequest(); + request.setApplId("TESTAPP"); + request.setEmailId("test@company.com"); + + MapperResponse mapperResponse = new MapperResponse("ZOSUSER", 0, 0, 0, 0); + + when(nativeMapper.getUserIDForDN("test@company.com", "testRegistry")) + .thenReturn(mapperResponse); + when(passTicketService.generate("ZOSUSER", "TESTAPP")) + .thenReturn("TICKET123"); + + ResponseEntity response = + SecurityTokenServiceController.getPassTicket(request); + + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("TICKET123", response.getBody().getPassticket()); + assertEquals("ZOSUSER", response.getBody().getTsoUserid()); + + verify(nativeMapper) + .getUserIDForDN("test@company.com", "testRegistry"); + verify(passTicketService) + .generate("ZOSUSER", "TESTAPP"); + } + + @Test + void shouldReturnPassTicketWhenMapperReturnsEmptyUser() throws Exception { + SecurityTokenServiceController.PassTicketRequest request = new SecurityTokenServiceController.PassTicketRequest(); + request.setApplId("APPID"); + request.setEmailId("test@company.com"); + + MapperResponse mapperResponse = new MapperResponse("", 0, 0, 0, 0); + + when(nativeMapper.getUserIDForDN(anyString(), anyString())) + .thenReturn(mapperResponse); + when(passTicketService.generate("", "APPID")) + .thenReturn("TICKET123"); + + ResponseEntity response = + SecurityTokenServiceController.getPassTicket(request); + + assertEquals(200, response.getStatusCode().value()); + assertEquals("TICKET123", response.getBody().getPassticket()); + assertEquals("", response.getBody().getTsoUserid()); + } + } + + @Nested + class BadRequests { + + @Test + void shouldReturnBadRequestWhenEmailIsBlank() throws Exception { + SecurityTokenServiceController.PassTicketRequest request = new SecurityTokenServiceController.PassTicketRequest(); + request.setApplId("APPID"); + request.setEmailId(""); + + ResponseEntity response = + SecurityTokenServiceController.getPassTicket(request); + + assertEquals(400, response.getStatusCode().value()); + verifyNoInteractions(passTicketService, nativeMapper); + } + + @Test + void shouldReturnBadRequestWhenApplIdIsBlank() throws Exception { + SecurityTokenServiceController.PassTicketRequest request = new SecurityTokenServiceController.PassTicketRequest(); + request.setEmailId("test@company.com"); + request.setApplId(""); + + ResponseEntity response = + SecurityTokenServiceController.getPassTicket(request); + + assertEquals(400, response.getStatusCode().value()); + verifyNoInteractions(passTicketService, nativeMapper); + } + } + + @Nested + class FailureScenarios { + + @Test + void shouldPropagateExceptionWhenNativeMapperFails() throws Exception { + SecurityTokenServiceController.PassTicketRequest request = new SecurityTokenServiceController.PassTicketRequest(); + request.setApplId("APPID"); + request.setEmailId("test@company.com"); + + when(nativeMapper.getUserIDForDN(anyString(), anyString())) + .thenThrow(new RuntimeException("Mapper failed")); + + RuntimeException exception = assertThrows( + RuntimeException.class, + () -> SecurityTokenServiceController.getPassTicket(request) + ); + + assertEquals("Mapper failed", exception.getMessage()); + } + } +} diff --git a/zaas-service/src/test/resources/application.yml b/zaas-service/src/test/resources/application.yml index 9c018d26e3..4ca87fb6c4 100644 --- a/zaas-service/src/test/resources/application.yml +++ b/zaas-service/src/test/resources/application.yml @@ -52,6 +52,8 @@ apiml: urls: authenticate: https://localhost:10013/zss/saf/authenticate verify: https://localhost:10013/zss/saf/verify + delegatePassticket: + enabled: true health: protected: false spring: