Implement JSON security context serializer (new default) - smaller info and human readable (#2652)

keeps backward compatibility by being able to fallback to JAVA_SERIALIZATION

+ fix DMF messages with status code

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
Avgustin Marinov
2025-09-05 13:35:45 +03:00
committed by GitHub
parent e37fe75f15
commit 1f71e01318
14 changed files with 338 additions and 52 deletions

View File

@@ -14,13 +14,27 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serial;
import java.io.Serializable;
import java.util.Base64;
import java.util.Collection;
import java.util.Objects;
import java.util.stream.Stream;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.eclipse.hawkbit.tenancy.TenantAwareAuthenticationDetails;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
// serializer for security contexts used for background tasks (processing auto assignments and rollouts)
// the user context is serialized on task creation and then is deserialized and applied when task is executed later
public interface SecurityContextSerializer {
/**
@@ -29,8 +43,14 @@ public interface SecurityContextSerializer {
*/
SecurityContextSerializer NOP = new Nop();
/**
* Serializer the uses Java serialization of {@link java.io.Serializable} objects.
* <p/>
* Serializer the uses JSON serialization.
* <p/>
* Note that on deserialization this serialization does (if configured) fallback to {@link #JAVA_SERIALIZATION}.
*/
SecurityContextSerializer JSON_SERIALIZATION = new JsonSerialization();
/**
* Serializer the uses Java serialization of {@link java.io.Serializable} objects (legacy, not recommended).
* <p/>
* Note that serialized via java serialization context might become unreadable if incompatible
* changes are made to the object classes.
*/
@@ -68,11 +88,128 @@ public interface SecurityContextSerializer {
}
@Override
public SecurityContext deserialize(String securityContextString) {
public SecurityContext deserialize(final String securityContextString) {
throw new UnsupportedOperationException();
}
}
/**
* Implementation based on the java serialization.
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@SuppressWarnings("java:S112") // accepted
class JsonSerialization implements SecurityContextSerializer {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final boolean FALLBACK_TO_JAVA_SERIALIZATION =
!Boolean.getBoolean("hawkbit.security.contextSerializer.json.no-fallback-to-java");
@Override
public String serialize(final SecurityContext securityContext) {
Objects.requireNonNull(securityContext);
try {
return OBJECT_MAPPER.writeValueAsString(new SecCtxInfo(securityContext));
} catch (final JsonProcessingException e) {
throw new RuntimeException(e);
}
}
@Override
public SecurityContext deserialize(final String securityContextString) {
Objects.requireNonNull(securityContextString);
final String securityContextTrimmed = securityContextString.trim();
try {
// java serialization starts with {@link ObjectStreamConstants#STREAM_MAGIC} (0xAC, 0xED) bytes
// while trimmed json object starts with '{'
if (FALLBACK_TO_JAVA_SERIALIZATION &&
(securityContextTrimmed.isEmpty() || securityContextTrimmed.charAt(0) != '{')) {
return JAVA_SERIALIZATION.deserialize(securityContextString);
}
return OBJECT_MAPPER.readerFor(SecCtxInfo.class).<SecCtxInfo> readValue(securityContextTrimmed).toSecurityContext();
} catch (final JsonProcessingException e) {
throw new RuntimeException(e);
}
}
// simplified info for the security context keeping just the basic info needed for background execution of
@NoArgsConstructor
@Data
private static class SecCtxInfo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String principal;
private String[] authorities;
private boolean authenticated;
private String tenant;
private boolean controller;
SecCtxInfo(final SecurityContext securityContext) {
final Authentication authentication = securityContext.getAuthentication();
if (authentication.getDetails() instanceof TenantAwareAuthenticationDetails tenantAwareDetails) {
tenant = tenantAwareDetails.tenant();
controller = tenantAwareDetails.controller();
} else {
tenant = null;
controller = false;
}
// keep the auditor, ofr audit purposes,
// sets principal to the resolved auditor and then deserialized authentication will return it as principal
// since the class is not known to auditor aware - it shall used default - principal as auditor
principal = SpringSecurityAuditorAware.resolveAuditor(authentication);
authorities = authentication.getAuthorities().stream().map(Object::toString).toArray(String[]::new);
authenticated = authentication.isAuthenticated();
}
private SecurityContext toSecurityContext() {
final SecurityContext ctx = SecurityContextHolder.createEmptyContext();
final Object details = tenant == null ? null : new TenantAwareAuthenticationDetails(tenant, controller);
final Collection<? extends GrantedAuthority> grantedAuthorities =
Stream.of(authorities).map(SimpleGrantedAuthority::new).toList();
ctx.setAuthentication(new Authentication() {
@Override
public Object getPrincipal() {
return principal;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return grantedAuthorities;
}
@Override
public boolean isAuthenticated() {
return authenticated;
}
@Override
public Object getDetails() {
return details;
}
@Override
public Object getCredentials() {
return null;
}
@Override
public void setAuthenticated(final boolean isAuthenticated) throws IllegalArgumentException {
throw new UnsupportedOperationException();
}
@Override
public String getName() {
return principal;
}
});
return ctx;
}
}
}
/**
* Implementation based on the java serialization.
*/
@@ -94,7 +231,7 @@ public interface SecurityContextSerializer {
}
@Override
public SecurityContext deserialize(String securityContextString) {
public SecurityContext deserialize(final String securityContextString) {
Objects.requireNonNull(securityContextString);
try (final ByteArrayInputStream bais = new ByteArrayInputStream(Base64.getDecoder().decode(securityContextString));
final ObjectInputStream ois = new ObjectInputStream(bais)) {

View File

@@ -11,6 +11,7 @@ package org.eclipse.hawkbit.security;
import java.util.Optional;
import lombok.NonNull;
import org.eclipse.hawkbit.tenancy.TenantAwareAuthenticationDetails;
import org.springframework.data.domain.AuditorAware;
import org.springframework.security.core.Authentication;
@@ -19,8 +20,7 @@ import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
/**
* Auditor class that allows BaseEntitys to insert current logged in user for
* repository changes.
* Auditor class that allows BaseEntity-s to insert current logged in user for repository changes.
*/
public class SpringSecurityAuditorAware implements AuditorAware<String> {
@@ -43,6 +43,7 @@ public class SpringSecurityAuditorAware implements AuditorAware<String> {
AUDITOR_OVERRIDE.remove();
}
@NonNull
@Override
public Optional<String> getCurrentAuditor() {
if (AUDITOR_OVERRIDE.get() != null) {
@@ -55,10 +56,10 @@ public class SpringSecurityAuditorAware implements AuditorAware<String> {
return Optional.empty();
}
return Optional.ofNullable(getCurrentAuditor(authentication));
return Optional.ofNullable(resolveAuditor(authentication));
}
protected String getCurrentAuditor(final Authentication authentication) {
public static String resolveAuditor(final Authentication authentication) {
if (authentication.getDetails() instanceof TenantAwareAuthenticationDetails tenantAwareDetails && tenantAwareDetails.controller()) {
return "CONTROLLER_PLUG_AND_PLAY";
}

View File

@@ -0,0 +1,91 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.security;
import static org.assertj.core.api.Assertions.assertThat;
import static org.eclipse.hawkbit.security.SecurityContextSerializer.JAVA_SERIALIZATION;
import static org.eclipse.hawkbit.security.SecurityContextSerializer.JSON_SERIALIZATION;
import java.util.List;
import java.util.Map;
import org.eclipse.hawkbit.im.authentication.SpPermission;
import org.eclipse.hawkbit.tenancy.TenantAwareAuthenticationDetails;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
class SecurityContextSerializerTest {
private static final List<String> AUTHORITIES = SpPermission.getAllAuthorities();
@Test
void testJsonSerialization() {
final SecurityContext securityContext = SecurityContextHolder.getContext();
final UsernamePasswordAuthenticationToken userPassAuthentication = new UsernamePasswordAuthenticationToken(
"user", null, AUTHORITIES.stream().map(SimpleGrantedAuthority::new).toList());
final TenantAwareAuthenticationDetails details = new TenantAwareAuthenticationDetails("my_tenant", false);
userPassAuthentication.setDetails(details);
securityContext.setAuthentication(userPassAuthentication);
final String serialized = JSON_SERIALIZATION.serialize(securityContext);
final SecurityContext deserialized = JSON_SERIALIZATION.deserialize(serialized);
final Authentication authentication = deserialized.getAuthentication();
assertThat(authentication.getPrincipal()).hasToString("user");
assertThat(authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList()).isEqualTo(AUTHORITIES);
assertThat(authentication.isAuthenticated()).isTrue();
assertThat(authentication.getDetails()).isEqualTo(details);
}
@Test
void testJsonSerializationSize() {
final SecurityContext securityContext = SecurityContextHolder.getContext();
final UsernamePasswordAuthenticationToken userPassAuthentication = new UsernamePasswordAuthenticationToken(
"FirstName.FamilyName@domain1.domain0.com",
Map.of("should not be in" + bigString(10_000),"the output" + bigString(15_000)),
AUTHORITIES.stream().map(SimpleGrantedAuthority::new).toList());
final TenantAwareAuthenticationDetails details = new TenantAwareAuthenticationDetails("my_test_enant", false);
userPassAuthentication.setDetails(details);
securityContext.setAuthentication(userPassAuthentication);
final String serialized = JSON_SERIALIZATION.serialize(securityContext);
assertThat(serialized).hasSizeLessThan(4096); // ensure that it is not too big
}
// test JSON serialization fallback to java serialization
@Test
void backwardCompatibilityOfJavaSerialization() {
final SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(
new UsernamePasswordAuthenticationToken("user", null, AUTHORITIES.stream().map(SimpleGrantedAuthority::new).toList()));
final String newSerialized = JSON_SERIALIZATION.serialize(securityContext);
final String oldSerialized = JAVA_SERIALIZATION.serialize(securityContext);
assertThat(oldSerialized).isNotEqualTo(newSerialized);
final Authentication deserializedOld = JSON_SERIALIZATION.deserialize(oldSerialized).getAuthentication();
final Authentication deserializedNew = JSON_SERIALIZATION.deserialize(newSerialized).getAuthentication();
assertThat(deserializedOld.getPrincipal()).hasToString(deserializedNew.getPrincipal().toString());
assertThat(deserializedOld.getAuthorities()).isEqualTo(deserializedNew.getAuthorities());
assertThat(deserializedOld.isAuthenticated()).isEqualTo(deserializedNew.isAuthenticated());
}
private static String bigString(final int length) {
final StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
sb.append((char) ('a' + i % 26));
}
return sb.toString();
}
}