Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,6 @@
*/
package org.hisp.dhis.user;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import java.util.Collection;
import java.util.Set;
import javax.annotation.Nonnull;
Expand All @@ -49,74 +45,8 @@
@Builder
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@Slf4j
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserDetailsImpl implements UserDetails {

@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public static UserDetailsImpl userDetailsMixin(
@JsonProperty("id") String uid,
@JsonProperty("code") String code,
@JsonProperty("username") String username,
@JsonProperty("firstName") String firstName,
@JsonProperty("surname") String surname,
@JsonProperty("password") String password,
@JsonProperty("externalAuth") boolean externalAuth,
@JsonProperty("isTwoFactorEnabled") boolean isTwoFactorEnabled,
@JsonProperty("twoFactorType") TwoFactorType twoFactorType,
@JsonProperty("secret") String secret,
@JsonProperty("email") String email,
@JsonProperty("isEmailVerified") boolean isEmailVerified,
@JsonProperty("enabled") boolean enabled,
@JsonProperty("accountNonExpired") boolean accountNonExpired,
@JsonProperty("accountNonLocked") boolean accountNonLocked,
@JsonProperty("credentialsNonExpired") boolean credentialsNonExpired,
@JsonProperty("dataViewMaxOrganisationUnitLevel") int dataViewMaxOrganisationUnitLevel,
@JsonProperty("authorities") Collection<GrantedAuthority> authorities,
@JsonProperty("allAuthorities") Set<String> allAuthorities,
@JsonProperty("allRestrictions") Set<String> allRestrictions,
@JsonProperty("userGroupIds") Set<String> userGroupIds,
@JsonProperty("userOrgUnitIds") Set<String> userOrgUnitIds,
@JsonProperty("userDataOrgUnitIds") Set<String> userDataOrgUnitIds,
@JsonProperty("userSearchOrgUnitIds") Set<String> userSearchOrgUnitIds,
@JsonProperty("userEffectiveSearchOrgUnitIds") Set<String> userEffectiveSearchOrgUnitIds,
@JsonProperty("isSuper") boolean isSuper,
@JsonProperty("userRoleIds") Set<String> userRoleIds,
@JsonProperty("managedGroupLongIds") Set<Long> managedGroupLongIds,
@JsonProperty("userRoleLongIds") Set<Long> userRoleLongIds) {
return UserDetailsImpl.builder()
.uid(uid)
.code(code)
.username(username)
.firstName(firstName)
.surname(surname)
.password(password)
.externalAuth(externalAuth)
.isTwoFactorEnabled(isTwoFactorEnabled)
.twoFactorType(twoFactorType)
.secret(secret)
.email(email)
.isEmailVerified(isEmailVerified)
.enabled(enabled)
.accountNonExpired(accountNonExpired)
.accountNonLocked(accountNonLocked)
.credentialsNonExpired(credentialsNonExpired)
.dataViewMaxOrganisationUnitLevel(dataViewMaxOrganisationUnitLevel)
.authorities(authorities)
.allAuthorities(allAuthorities)
.allRestrictions(allRestrictions)
.userGroupIds(userGroupIds)
.userOrgUnitIds(userOrgUnitIds)
.userDataOrgUnitIds(userDataOrgUnitIds)
.userSearchOrgUnitIds(userSearchOrgUnitIds)
.userEffectiveSearchOrgUnitIds(userEffectiveSearchOrgUnitIds)
.isSuper(isSuper)
.userRoleIds(userRoleIds)
.managedGroupLongIds(managedGroupLongIds)
.userRoleLongIds(userRoleLongIds)
.build();
}

private final String uid;
@Setter private Long id;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.security.Principal;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
Expand All @@ -48,6 +50,7 @@
import org.hisp.dhis.user.UserDetails;
import org.hisp.dhis.user.UserService;
import org.springframework.dao.DataRetrievalFailureException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.jackson2.SecurityJackson2Modules;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
Expand Down Expand Up @@ -381,7 +384,7 @@ private Dhis2OAuth2Authorization toEntity(OAuth2Authorization authorization) {
entity.setAuthorizationGrantType(authorization.getAuthorizationGrantType().getValue());
entity.setAuthorizedScopes(
StringUtils.collectionToCommaDelimitedString(authorization.getAuthorizedScopes()));
entity.setAttributes(writeMap(authorization.getAttributes()));
entity.setAttributes(writeMap(leanPrincipal(authorization.getAttributes())));
entity.setState(authorization.getAttribute(OAuth2ParameterNames.STATE));

OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode =
Expand Down Expand Up @@ -483,6 +486,41 @@ private Map<String, Object> parseMap(String data) {
}
}

/**
* Replaces a heavyweight DHIS2 principal in the authorization attributes with a lean,
* Spring-native one before persistence.
*
* <p>After a federated OIDC login the {@code java.security.Principal} attribute is an {@link
* org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken} whose
* principal is a {@link org.hisp.dhis.security.oidc.DhisOidcUser} wrapping a {@link
* org.hisp.dhis.user.UserDetailsImpl}; a form login carries a {@code UserDetailsImpl} directly.
* Spring Authorization Server only ever reads the principal's {@link Authentication#getName()
* name} (the JWT/token customizer) and re-loads the user from the database on token validation,
* so the heavyweight object graph is dead weight in the row and the reason those DHIS2-custom
* types tripped {@link SecurityJackson2Modules}' deserialization allowlist. Swapping it for a
* {@link UsernamePasswordAuthenticationToken} carrying just the DHIS2 username and authorities
* keeps the persisted attributes entirely Spring-native, so they round-trip without any custom
* Jackson mixins. {@code principalName} is left untouched (the consent store keys on it). The
* token's {@code authorizedClientRegistrationId} (e.g. {@code "google"}) and the authentication
* {@code details} are intentionally not retained — nothing in the token-issuance or validation
* path reads them once the user is identified by name.
*
* <p>Package-private (rather than {@code private}) so the branch logic can be unit-tested
* directly.
*/
static Map<String, Object> leanPrincipal(Map<String, Object> attributes) {
if (attributes.get(Principal.class.getName()) instanceof Authentication authentication
&& authentication.getPrincipal() instanceof UserDetails userDetails) {
Map<String, Object> lean = new LinkedHashMap<>(attributes);
lean.put(
Principal.class.getName(),
new UsernamePasswordAuthenticationToken(
userDetails.getUsername(), null, userDetails.getAuthorities()));
return lean;
}
return attributes;
}

/**
* Converts a Map to a JSON string.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* Copyright (c) 2004-2025, 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.oauth2.authorization;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.security.Principal;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.hisp.dhis.security.oidc.DhisOidcUser;
import org.hisp.dhis.user.UserDetailsImpl;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;

/**
* Unit tests for {@link Dhis2OAuth2AuthorizationServiceImpl#leanPrincipal} — the swap that keeps
* the persisted OAuth2 authorization principal Spring-native (so it never trips the Jackson
* allowlist).
*/
class LeanPrincipalTest {

@Test
void noPrincipalAttribute_isUnchanged() {
Map<String, Object> attributes = new LinkedHashMap<>();
attributes.put("state", "xyz");
assertSame(attributes, Dhis2OAuth2AuthorizationServiceImpl.leanPrincipal(attributes));
}

@Test
void nonUserDetailsPrincipal_isUnchanged() {
// client_credentials-style: an Authentication whose principal is the client id (a String).
Map<String, Object> attributes = new LinkedHashMap<>();
attributes.put(
Principal.class.getName(),
new UsernamePasswordAuthenticationToken("client-id", null, List.of()));
assertSame(attributes, Dhis2OAuth2AuthorizationServiceImpl.leanPrincipal(attributes));
}

@Test
void formLoginUserDetailsImplPrincipal_isLeaned() {
UserDetailsImpl userDetails = userDetails("formuser");
Map<String, Object> attributes = new LinkedHashMap<>();
attributes.put(
Principal.class.getName(),
new UsernamePasswordAuthenticationToken(
userDetails, "secret", userDetails.getAuthorities()));

assertLean(Dhis2OAuth2AuthorizationServiceImpl.leanPrincipal(attributes), "formuser");
}

@Test
void federatedDhisOidcUserPrincipal_isLeaned() {
UserDetailsImpl userDetails = userDetails("oidcuser");
Map<String, Object> oidcClaims = Map.of(IdTokenClaimNames.SUB, "google-sub-1");
OidcIdToken idToken =
OidcIdToken.withTokenValue("id-token")
.subject("google-sub-1")
.claims(c -> c.putAll(oidcClaims))
.build();
DhisOidcUser oidcPrincipal =
new DhisOidcUser(userDetails, oidcClaims, IdTokenClaimNames.SUB, idToken);
Map<String, Object> attributes = new LinkedHashMap<>();
attributes.put(
Principal.class.getName(),
new OAuth2AuthenticationToken(oidcPrincipal, oidcPrincipal.getAuthorities(), "google"));

// The DhisOidcUser's getName() is the IdP sub; the leaned principal must carry the DHIS2
// username.
assertLean(Dhis2OAuth2AuthorizationServiceImpl.leanPrincipal(attributes), "oidcuser");
}

private static void assertLean(Map<String, Object> result, String expectedUsername) {
Object principal = result.get(Principal.class.getName());
assertInstanceOf(UsernamePasswordAuthenticationToken.class, principal);
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) principal;
assertEquals(expectedUsername, token.getName());
assertTrue(token.isAuthenticated());
assertEquals(
Set.of("ALL"),
token.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet()));
}

private static UserDetailsImpl userDetails(String username) {
return UserDetailsImpl.builder()
.uid("uid-" + username)
.username(username)
.authorities(new ArrayList<>(List.of(new SimpleGrantedAuthority("ALL"))))
.allAuthorities(new HashSet<>(Set.of("ALL")))
.allRestrictions(new HashSet<>())
.userGroupIds(new HashSet<>())
.userOrgUnitIds(new HashSet<>())
.userDataOrgUnitIds(new HashSet<>())
.userSearchOrgUnitIds(new HashSet<>())
.userEffectiveSearchOrgUnitIds(new HashSet<>())
.userRoleIds(new HashSet<>())
.managedGroupLongIds(new HashSet<>())
.userRoleLongIds(new HashSet<>())
.build();
}
}
5 changes: 5 additions & 0 deletions dhis-2/dhis-test-integration/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,11 @@
<artifactId>quick</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-core</artifactId>
Expand Down
Loading
Loading