From 4a7f4e5d957db4fd9bb8c807f3dde5f2448f99ba Mon Sep 17 00:00:00 2001 From: Jakub Balhar Date: Fri, 10 Jul 2026 10:24:50 +0200 Subject: [PATCH] feat: detect z/OSMF expired password (SAFReturnCode=8, SAFReasonCode=24) Add isExpiredPassword() helper that parses z/OSMF 401 response body for SAF messages indicating an expired password. Intercept HttpClientErrorException.Unauthorized in issueAuthenticationRequest() before the generic RuntimeException catch. When expired password is detected, throw ZosAuthenticationException(EMVSEXPIRE, errno 168). Closes #4083 Signed-off-by: Jakub Balhar --- .../security/service/zosmf/ZosmfService.java | 41 +++++++ .../service/zosmf/ZosmfServiceTest.java | 112 ++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/zaas-service/src/main/java/org/zowe/apiml/zaas/security/service/zosmf/ZosmfService.java b/zaas-service/src/main/java/org/zowe/apiml/zaas/security/service/zosmf/ZosmfService.java index be0f30c07f..60718a4557 100644 --- a/zaas-service/src/main/java/org/zowe/apiml/zaas/security/service/zosmf/ZosmfService.java +++ b/zaas-service/src/main/java/org/zowe/apiml/zaas/security/service/zosmf/ZosmfService.java @@ -12,6 +12,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; import lombok.AllArgsConstructor; @@ -46,7 +47,9 @@ import org.springframework.web.client.HttpServerErrorException; import org.springframework.web.client.RestTemplate; import org.zowe.apiml.security.common.config.AuthConfigurationProperties; +import org.zowe.apiml.security.common.auth.saf.PlatformReturned; import org.zowe.apiml.security.common.error.ServiceNotAccessibleException; +import org.zowe.apiml.security.common.error.ZosAuthenticationException; import org.zowe.apiml.security.common.login.ChangePasswordRequest; import org.zowe.apiml.security.common.login.LoginRequest; import org.zowe.apiml.security.common.token.TokenNotValidException; @@ -306,6 +309,36 @@ private String getURI(String serviceId, String path) { return UrlUtils.buildFullRequestUrl(url.getProtocol(), url.getHost(), url.getPort(), path, null); } + /** + * Check whether a 401 response from z/OSMF indicates an expired password. + * + * @param e the HttpClientErrorException.Unauthorized exception with the response body + * @return true if the response body contains SAFReturnCode=8 and SAFReasonCode=24 + */ + private boolean isExpiredPassword(HttpClientErrorException.Unauthorized e) { + byte[] responseBody = e.getResponseBodyAsByteArray(); + if (responseBody == null || responseBody.length == 0) { + return false; + } + try { + JsonNode root = securityObjectMapper.readTree(responseBody); + JsonNode safMessages = root.get("safMessages"); + if (safMessages != null && safMessages.isArray()) { + for (JsonNode message : safMessages) { + JsonNode safReturnCode = message.get("SAFReturnCode"); + JsonNode safReasonCode = message.get("SAFReasonCode"); + if (safReturnCode != null && safReturnCode.asInt() == 8 + && safReasonCode != null && safReasonCode.asInt() == 24) { + return true; + } + } + } + } catch (IOException e1) { + log.debug("Error parsing z/OSMF 401 response body: {}", e1.getMessage()); + } + return false; + } + /** * POST to provided url and return authentication response * @@ -324,6 +357,14 @@ protected AuthenticationResponse issueAuthenticationRequest(Authentication authe httpMethod, new HttpEntity<>(null, headers), String.class); return getAuthenticationResponse(response); + } catch (HttpClientErrorException.Unauthorized e) { + if (isExpiredPassword(e)) { + throw new ZosAuthenticationException(PlatformReturned.builder() + .errno(168) + .errnoMsg("org.zowe.apiml.security.platform.errno.EMVSEXPIRE") + .build()); + } + throw handleExceptionOnCall(url, e); } catch (RuntimeException re) { throw handleExceptionOnCall(url, re); } diff --git a/zaas-service/src/test/java/org/zowe/apiml/zaas/security/service/zosmf/ZosmfServiceTest.java b/zaas-service/src/test/java/org/zowe/apiml/zaas/security/service/zosmf/ZosmfServiceTest.java index def03654c0..ceed465fba 100644 --- a/zaas-service/src/test/java/org/zowe/apiml/zaas/security/service/zosmf/ZosmfServiceTest.java +++ b/zaas-service/src/test/java/org/zowe/apiml/zaas/security/service/zosmf/ZosmfServiceTest.java @@ -51,7 +51,9 @@ import org.springframework.web.client.RestTemplate; import org.zowe.apiml.message.log.ApimlLogger; import org.zowe.apiml.security.common.config.AuthConfigurationProperties; +import org.zowe.apiml.security.common.error.PlatformPwdErrno; import org.zowe.apiml.security.common.error.ServiceNotAccessibleException; +import org.zowe.apiml.security.common.error.ZosAuthenticationException; import org.zowe.apiml.security.common.login.ChangePasswordRequest; import org.zowe.apiml.security.common.login.LoginRequest; import org.zowe.apiml.security.common.token.TokenNotValidException; @@ -1048,4 +1050,114 @@ void givenOtherAuthSourceAndZosmaIsNotAvailable_thenExceptionIsThrown() { } + @Nested + class GivenExpiredPasswordResponse { + + private ZosmfService zosmfService; + private Authentication authentication; + + @BeforeEach + void setUp() { + zosmfService = getZosmfServiceSpy(); + doReturn(true).when(zosmfService).loginEndpointExists(); + authentication = new UsernamePasswordAuthenticationToken("user", "pass"); + } + + @Test + void whenZosmfReturnsExpiredPassword_thenThrowZosAuthenticationException() { + String responseBody = """ + { + "safMessages": [ + { + "SAFReturnCode": 8, + "SAFReasonCode": 24, + "SAFMessageText": "ICH408I USER(user) EXPIRED PASSWORD" + } + ] + } + """; + HttpClientErrorException.Unauthorized exception = (HttpClientErrorException.Unauthorized) + HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, "Unauthorized", null, + responseBody.getBytes(), null); + + doThrow(exception).when(restTemplate).exchange( + anyString(), + any(HttpMethod.class), + any(HttpEntity.class), + (Class) any() + ); + + ZosAuthenticationException thrown = assertThrows(ZosAuthenticationException.class, + () -> zosmfService.authenticate(authentication)); + assertEquals(PlatformPwdErrno.EMVSEXPIRE, thrown.getPlatformError()); + } + + @Test + void whenZosmfReturnsInvalidCredentials_thenThrowBadCredentialsException() { + String responseBody = """ + { + "safMessages": [ + { + "SAFReturnCode": 8, + "SAFReasonCode": 16, + "SAFMessageText": "ICH408I USER(user) INVALID PASSWORD" + } + ] + } + """; + HttpClientErrorException.Unauthorized exception = (HttpClientErrorException.Unauthorized) + HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, "Unauthorized", null, + responseBody.getBytes(), null); + + doThrow(exception).when(restTemplate).exchange( + anyString(), + any(HttpMethod.class), + any(HttpEntity.class), + (Class) any() + ); + + assertThrows(BadCredentialsException.class, + () -> zosmfService.authenticate(authentication)); + } + + @Test + void whenZosmfReturnsEmptyBody_thenThrowBadCredentialsException() { + HttpClientErrorException.Unauthorized exception = (HttpClientErrorException.Unauthorized) + HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, "Unauthorized", null, + new byte[0], null); + + doThrow(exception).when(restTemplate).exchange( + anyString(), + any(HttpMethod.class), + any(HttpEntity.class), + (Class) any() + ); + + assertThrows(BadCredentialsException.class, + () -> zosmfService.authenticate(authentication)); + } + + @Test + void whenZosmfReturnsMalformedJson_thenThrowBadCredentialsException() { + String malformedJson = "{ not valid json }"; + HttpClientErrorException.Unauthorized exception = (HttpClientErrorException.Unauthorized) + HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, "Unauthorized", null, + malformedJson.getBytes(), null); + + doThrow(exception).when(restTemplate).exchange( + anyString(), + any(HttpMethod.class), + any(HttpEntity.class), + (Class) any() + ); + + assertThrows(BadCredentialsException.class, + () -> zosmfService.authenticate(authentication)); + } + } + }