diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/security/ApiTokenAuthTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/security/ApiTokenAuthTest.java new file mode 100644 index 000000000000..d04b0c126c78 --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/security/ApiTokenAuthTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.BaseE2ETest; +import org.hisp.dhis.login.CodeGenerator; +import org.hisp.dhis.test.e2e.helpers.config.TestConfiguration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestClientResponseException; + +/** + * @author Torbjørn Lindahl + */ +@Slf4j +class ApiTokenAuthTest extends BaseE2ETest { + + @BeforeAll + static void setup() throws JsonProcessingException { + serverApiUrl = TestConfiguration.get().baseUrl(); + serverHostUrl = TestConfiguration.get().baseUrl().replace("/api", ""); + orgUnitUID = createOrgUnit(); + } + + /** + * Regression test for LazyInitializationException on User.userRoles when authenticating with a + * personal access token (PAT) on an endpoint excluded from open-session-in-view ({@code + * ConditionalOpenEntityManagerInViewFilter}), such as {@code /api/dataValueSets}. Before the fix, + * {@code ApiTokenAuthManager} looked up the token owner and created {@code UserDetails} outside a + * transaction; the first request with a not-yet-cached token on such an endpoint then failed with + * a 500 error. The fix creates the {@code UserDetails} within a single transaction via {@code + * UserService.createUserDetailsByUsername}, mirroring the JWT bearer fix (#23611). + */ + @Test + void testPatAuthenticationOnNonOsivEndpoint() throws JsonProcessingException { + String username = CodeGenerator.generateCode(8).toLowerCase(); + String password = "Test123###..."; + + createSuperuser(username, password, orgUnitUID); + + // Create a PAT as the new user; the plaintext key is only returned on creation. + String cookie = performInitialLogin(username, password); + ResponseEntity tokenResponse = postWithCookie("/apiTokens", Map.of(), cookie); + assertEquals(HttpStatus.CREATED, tokenResponse.getStatusCode()); + JsonNode tokenJson = objectMapper.readTree(tokenResponse.getBody()); + String plaintextToken = tokenJson.get("response").get("key").asText(); + assertNotNull(plaintextToken); + + // First request with this token: the token is not in the server's token cache, so + // authentication resolves the owner and creates UserDetails during this request. The endpoint + // is excluded from OSIV, so no request-scoped Hibernate session exists. Before the fix this + // returned 500 (LazyInitializationException on User.userRoles); with the fix, authentication + // succeeds and the endpoint's parameter validation responds 409 Conflict. + ResponseEntity response = getWithApiTokenAuth("/dataValueSets", plaintextToken); + log.info( + "dataValueSets response: status={}, body={}", response.getStatusCode(), response.getBody()); + + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + } + + private static ResponseEntity getWithApiTokenAuth(String path, String token) { + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.AUTHORIZATION, "ApiToken " + token); + try { + return restTemplate.exchange( + serverApiUrl + path, HttpMethod.GET, new HttpEntity<>(headers), String.class); + } catch (RestClientResponseException e) { + return ResponseEntity.status(e.getStatusCode()).body(e.getResponseBodyAsString()); + } + } +} diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/apikey/ApiTokenAuthManager.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/apikey/ApiTokenAuthManager.java index 90ef98a5c840..b39604246e08 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/apikey/ApiTokenAuthManager.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/apikey/ApiTokenAuthManager.java @@ -29,12 +29,9 @@ */ package org.hisp.dhis.webapi.security.apikey; -import java.util.HashSet; -import java.util.List; import java.util.Optional; import org.hisp.dhis.cache.Cache; import org.hisp.dhis.cache.CacheProvider; -import org.hisp.dhis.organisationunit.OrganisationUnitService; import org.hisp.dhis.security.apikey.ApiToken; import org.hisp.dhis.security.apikey.ApiTokenAuthenticationToken; import org.hisp.dhis.security.apikey.ApiTokenDeletedEvent; @@ -42,7 +39,6 @@ import org.hisp.dhis.user.User; import org.hisp.dhis.user.UserDetails; import org.hisp.dhis.user.UserService; -import org.hisp.dhis.user.UserStore; import org.hisp.dhis.util.ObjectUtils; import org.springframework.context.annotation.Lazy; import org.springframework.context.event.EventListener; @@ -59,22 +55,14 @@ @Service public class ApiTokenAuthManager implements AuthenticationManager { private final ApiTokenService apiTokenService; - private final OrganisationUnitService organisationUnitService; private final UserService userService; - private final UserStore userStore; private final Cache apiTokenCache; public ApiTokenAuthManager( - UserStore userStore, - ApiTokenService apiTokenService, - CacheProvider cacheProvider, - @Lazy UserService userService, - OrganisationUnitService organisationUnitService) { + ApiTokenService apiTokenService, CacheProvider cacheProvider, @Lazy UserService userService) { this.userService = userService; - this.userStore = userStore; this.apiTokenService = apiTokenService; - this.organisationUnitService = organisationUnitService; this.apiTokenCache = cacheProvider.createApiKeyCache(); } @@ -118,18 +106,20 @@ private UserDetails validateAndCreateUserDetails(User createdBy) { ApiTokenErrors.invalidToken("The API token does not have any owner.")); } - User user = userStore.getUserByUsername(createdBy.getUsername()); - if (user == null) { + // User lookup and UserDetails creation must happen within a single transaction, so that lazy + // collections (e.g. User.userRoles) are accessible. This code can run on endpoints excluded + // from open-session-in-view, where no Hibernate session is otherwise available. + UserDetails userDetails = userService.createUserDetailsByUsername(createdBy.getUsername()); + if (userDetails == null) { throw new ApiTokenAuthenticationException( ApiTokenErrors.invalidToken("The API token owner does not exists.")); } - boolean isTwoFactorDisabled = !user.isTwoFactorEnabled(); - boolean enabled = !user.isDisabled(); - - boolean credentialsNonExpired = userService.userNonExpired(user); - boolean accountNonLocked = !userService.isLocked(user.getUsername()); - boolean accountNonExpired = user.isAccountNonExpired(); + boolean isTwoFactorDisabled = !userDetails.isTwoFactorEnabled(); + boolean enabled = userDetails.isEnabled(); + boolean credentialsNonExpired = userDetails.isCredentialsNonExpired(); + boolean accountNonLocked = userDetails.isAccountNonLocked(); + boolean accountNonExpired = userDetails.isAccountNonExpired(); if (ObjectUtils.anyIsFalse( enabled, isTwoFactorDisabled, credentialsNonExpired, accountNonLocked, accountNonExpired)) { @@ -138,20 +128,7 @@ private UserDetails validateAndCreateUserDetails(User createdBy) { ApiTokenErrors.invalidToken("The API token is disabled, locked or 2FA is enabled.")); } - List organisationUnitsUidsByUser = - organisationUnitService.getOrganisationUnitsUidsByUser(user.getUsername()); - List searchOrganisationUnitsUidsByUser = - organisationUnitService.getSearchOrganisationUnitsUidsByUser(user.getUsername()); - List dataViewOrganisationUnitsUidsByUser = - organisationUnitService.getDataViewOrganisationUnitsUidsByUser(user.getUsername()); - - return UserDetails.createUserDetails( - user, - accountNonLocked, - credentialsNonExpired, - new HashSet<>(organisationUnitsUidsByUser), - new HashSet<>(searchOrganisationUnitsUidsByUser), - new HashSet<>(dataViewOrganisationUnitsUidsByUser)); + return userDetails; } private static void validateTokenExpiry(Long expiry) {