diff --git a/jmix-core/core/src/main/java/io/jmix/core/security/SecurityContextHelper.java b/jmix-core/core/src/main/java/io/jmix/core/security/SecurityContextHelper.java index 083c7d30f7..78529ac82c 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/security/SecurityContextHelper.java +++ b/jmix-core/core/src/main/java/io/jmix/core/security/SecurityContextHelper.java @@ -23,6 +23,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.jspecify.annotations.Nullable; @@ -44,6 +45,10 @@ public static Authentication getAuthentication() { /** * Sets the Authentication in the current {@link SecurityContext}. + *

+ * Note that this method modifies the current {@link SecurityContext} instance, which may be shared with other + * threads of the same HTTP session. To execute code on behalf of another user for a limited time, + * use {@link SystemAuthenticator} instead. */ public static void setAuthentication(@Nullable Authentication authentication) { if (authentication != null) { @@ -54,4 +59,47 @@ public static void setAuthentication(@Nullable Authentication authentication) { LogMdc.setup(null); } } + + /** + * Makes the given context current for this thread only, without modifying the previously current context + * instance. If the installed {@link SecurityContextHolderStrategy} supports {@link ThreadSecurityContextOverride}, + * the context takes precedence over other sources such as the Vaadin session. Also sets up the logging MDC. + *

+ * Must be paired with {@link #restoreContext(SecurityContext)} in a "finally" block: + *

+     *     SecurityContext previous = SecurityContextHolder.getContext();
+     *     SecurityContextHelper.installContext(context);
+     *     try {
+     *         // ...
+     *     } finally {
+     *         SecurityContextHelper.restoreContext(previous);
+     *     }
+     * 
+ */ + public static void installContext(SecurityContext context) { + SecurityContextHolderStrategy strategy = SecurityContextHolder.getContextHolderStrategy(); + if (strategy instanceof ThreadSecurityContextOverride override) { + override.pushContext(context); + } else { + strategy.setContext(context); + } + LogMdc.setup(context.getAuthentication()); + } + + /** + * Reverts the matching {@link #installContext(SecurityContext)} call. + * + * @param previous the context that was current before {@code installContext()}, or null to clear the context + */ + public static void restoreContext(@Nullable SecurityContext previous) { + SecurityContextHolderStrategy strategy = SecurityContextHolder.getContextHolderStrategy(); + if (strategy instanceof ThreadSecurityContextOverride override) { + override.popContext(); + } else if (previous != null) { + strategy.setContext(previous); + } else { + strategy.clearContext(); + } + LogMdc.setup(previous != null ? previous.getAuthentication() : null); + } } diff --git a/jmix-core/core/src/main/java/io/jmix/core/security/ThreadSecurityContextOverride.java b/jmix-core/core/src/main/java/io/jmix/core/security/ThreadSecurityContextOverride.java new file mode 100644 index 0000000000..6e93b9507b --- /dev/null +++ b/jmix-core/core/src/main/java/io/jmix/core/security/ThreadSecurityContextOverride.java @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.jmix.core.security; + +import org.jspecify.annotations.NullMarked; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; + +/** + * Optional capability of a {@link SecurityContextHolderStrategy}: lets the current thread temporarily override the + * {@link SecurityContext} returned by {@link SecurityContextHolder#getContext()}, regardless of where the strategy + * would otherwise take the context from (for example, from the current Vaadin session). + *

+ * Used by {@link SystemAuthenticator} to make {@code begin()}/{@code end()} affect the current thread only. + * If the installed strategy does not implement this interface, {@link SystemAuthenticator} falls back to + * {@link SecurityContextHolder#setContext(SecurityContext)}, which is sufficient for a plain thread-local strategy. + *

+ * Implementations must keep a per-thread stack so that nested overrides work. + * {@link SecurityContextHolderStrategy#setContext(SecurityContext)} must replace the current override without + * discarding the enclosing scopes. {@link SecurityContextHolderStrategy#clearContext()} must clear the stack + * when a request or task releases its thread. + */ +@NullMarked +public interface ThreadSecurityContextOverride { + + /** + * Makes the given context the one returned by {@code getContext()} on the current thread until a matching + * {@link #popContext()} call. + */ + void pushContext(SecurityContext context); + + /** + * Removes the most recently pushed override from the current thread. Does nothing if there is no override. + */ + void popContext(); +} diff --git a/jmix-core/core/src/main/java/io/jmix/core/security/impl/SystemAuthenticatorImpl.java b/jmix-core/core/src/main/java/io/jmix/core/security/impl/SystemAuthenticatorImpl.java index 51ec11f4bf..14e6a52bca 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/security/impl/SystemAuthenticatorImpl.java +++ b/jmix-core/core/src/main/java/io/jmix/core/security/impl/SystemAuthenticatorImpl.java @@ -18,7 +18,6 @@ import com.google.common.base.Strings; import io.jmix.core.JmixOrder; -import io.jmix.core.impl.logging.LogMdc; import io.jmix.core.security.SecurityContextHelper; import io.jmix.core.security.SystemAuthenticationToken; import io.jmix.core.security.SystemAuthenticator; @@ -31,6 +30,8 @@ import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.jspecify.annotations.Nullable; @@ -65,7 +66,10 @@ public Authentication begin(@Nullable String login) { throw new IllegalStateException("AuthenticationManager is not defined"); } - pushAuthentication(SecurityContextHelper.getAuthentication()); + // The previous context is saved as an instance and never modified: it may be shared with other threads + // of the same HTTP session. + SecurityContext previous = SecurityContextHolder.getContext(); + pushSecurityContext(previous); try { Authentication authentication; @@ -79,12 +83,14 @@ public Authentication begin(@Nullable String login) { authentication = authenticationManager.authenticate(authToken); } - SecurityContextHelper.setAuthentication(authentication); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHelper.installContext(context); return authentication; } catch (AuthenticationException e) { - pollAuthentication(); + pollSecurityContext(); throw e; } } @@ -96,10 +102,9 @@ public Authentication begin() { @Override public void end() { - log.trace("Set previous Authentication"); - Authentication previous = pollAuthentication(); - SecurityContextHelper.setAuthentication(previous); - LogMdc.setup(previous); + log.trace("Set previous SecurityContext"); + SecurityContext previous = pollSecurityContext(); + SecurityContextHelper.restoreContext(previous); } @Override diff --git a/jmix-core/core/src/main/java/io/jmix/core/security/impl/SystemAuthenticatorSupport.java b/jmix-core/core/src/main/java/io/jmix/core/security/impl/SystemAuthenticatorSupport.java index 879c39c565..3959116a70 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/security/impl/SystemAuthenticatorSupport.java +++ b/jmix-core/core/src/main/java/io/jmix/core/security/impl/SystemAuthenticatorSupport.java @@ -16,28 +16,31 @@ package io.jmix.core.security.impl; -import io.jmix.core.security.SystemAuthenticationToken; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; import org.jspecify.annotations.Nullable; import java.util.ArrayDeque; import java.util.Deque; +/** + * Keeps a per-thread stack of {@link SecurityContext} instances that were current before each + * {@code begin()} call, so that {@code end()} can reinstall the exact previous instance. + *

+ * The stored instances are never modified. + */ public abstract class SystemAuthenticatorSupport { private static final Logger log = LoggerFactory.getLogger(SystemAuthenticatorSupport.class); - protected static final Authentication NULL_AUTHENTICATION = new NullAuthentication(); - - protected ThreadLocal> threadLocalStack = new ThreadLocal<>(); + protected ThreadLocal> threadLocalStack = new ThreadLocal<>(); public SystemAuthenticatorSupport() { } - protected void pushAuthentication(@Nullable Authentication authentication) { - Deque stack = threadLocalStack.get(); + protected void pushSecurityContext(SecurityContext securityContext) { + Deque stack = threadLocalStack.get(); if (stack == null) { stack = new ArrayDeque<>(); threadLocalStack.set(stack); @@ -46,24 +49,16 @@ protected void pushAuthentication(@Nullable Authentication authentication) { log.warn("Stack is too big: {}. Check correctness of begin/end invocations.", stack.size()); } } - if (authentication == null) { - stack.push(NULL_AUTHENTICATION); - } else { - stack.push(authentication); - } + stack.push(securityContext); } @Nullable - protected Authentication pollAuthentication() { - Deque stack = threadLocalStack.get(); + protected SecurityContext pollSecurityContext() { + Deque stack = threadLocalStack.get(); if (stack != null) { - Authentication authentication = stack.poll(); - if (authentication != null) { - if (authentication == NULL_AUTHENTICATION) { - return null; - } else { - return authentication; - } + SecurityContext securityContext = stack.poll(); + if (securityContext != null) { + return securityContext; } else { log.warn("Stack is empty. Check correctness of begin/end invocations."); } @@ -72,14 +67,4 @@ protected Authentication pollAuthentication() { } return null; } - - protected static class NullAuthentication extends SystemAuthenticationToken { - - private static final long serialVersionUID = 5437664860036209641L; - - public NullAuthentication() { - super(); - } - } - } diff --git a/jmix-core/core/src/test/groovy/security/SystemAuthenticatorTest.groovy b/jmix-core/core/src/test/groovy/security/SystemAuthenticatorTest.groovy index 5f66f01309..0ce7027428 100644 --- a/jmix-core/core/src/test/groovy/security/SystemAuthenticatorTest.groovy +++ b/jmix-core/core/src/test/groovy/security/SystemAuthenticatorTest.groovy @@ -21,7 +21,9 @@ import io.jmix.core.security.InMemoryUserRepository import io.jmix.core.security.SystemAuthenticationToken import io.jmix.core.security.SystemAuthenticator import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContext import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.userdetails.User import org.springframework.security.core.userdetails.UserDetails @@ -29,6 +31,10 @@ import org.springframework.test.context.ContextConfiguration import spock.lang.Specification import test_support.base.TestBaseConfiguration +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + @ContextConfiguration(classes = [CoreConfiguration, TestBaseConfiguration]) class SystemAuthenticatorTest extends Specification { @@ -139,4 +145,82 @@ class SystemAuthenticatorTest extends Specification { SecurityContextHolder.getContext().getAuthentication() == null } + + def "begin and end do not modify the previously current SecurityContext instance"() { + given: "a context of a logged-in user is current, as on a UI request thread" + Authentication adminAuth = new UsernamePasswordAuthenticationToken(admin, null, admin.authorities) + SecurityContext original = SecurityContextHolder.createEmptyContext() + original.setAuthentication(adminAuth) + SecurityContextHolder.setContext(original) + + when: + authenticator.begin() + + then: "the current thread sees system, but the original context object is untouched" + SecurityContextHolder.getContext().getAuthentication() instanceof SystemAuthenticationToken + original.getAuthentication().is(adminAuth) + + when: + authenticator.end() + + then: "the original context instance is current again" + SecurityContextHolder.getContext().is(original) + SecurityContextHolder.getContext().getAuthentication().is(adminAuth) + + cleanup: + SecurityContextHolder.clearContext() + } + + def "begin on one thread is not visible to another thread sharing the same SecurityContext instance"() { + given: "two threads hold the same context instance, as a UI thread and an async task do" + Authentication adminAuth = new UsernamePasswordAuthenticationToken(admin, null, admin.authorities) + SecurityContext shared = SecurityContextHolder.createEmptyContext() + shared.setAuthentication(adminAuth) + SecurityContextHolder.setContext(shared) + ExecutorService otherThread = Executors.newSingleThreadExecutor() + otherThread.submit { SecurityContextHolder.setContext(shared) }.get(5, TimeUnit.SECONDS) + + when: + authenticator.begin() + Authentication seenByOtherThread = otherThread.submit { + SecurityContextHolder.getContext().getAuthentication() + }.get(5, TimeUnit.SECONDS) + + then: + seenByOtherThread.is(adminAuth) + + cleanup: + authenticator.end() + otherThread.shutdownNow() + SecurityContextHolder.clearContext() + } + + def "overlapping begin and end on two threads leave the shared SecurityContext unchanged"() { + given: + Authentication adminAuth = new UsernamePasswordAuthenticationToken(admin, null, admin.authorities) + SecurityContext shared = SecurityContextHolder.createEmptyContext() + shared.setAuthentication(adminAuth) + SecurityContextHolder.setContext(shared) + ExecutorService otherThread = Executors.newSingleThreadExecutor() + otherThread.submit { SecurityContextHolder.setContext(shared) }.get(5, TimeUnit.SECONDS) + + when: "T1 begins, T2 begins, T1 ends, T2 ends" + authenticator.begin() + otherThread.submit { authenticator.begin() }.get(5, TimeUnit.SECONDS) + authenticator.end() + otherThread.submit { authenticator.end() }.get(5, TimeUnit.SECONDS) + + Authentication seenByOtherThread = otherThread.submit { + SecurityContextHolder.getContext().getAuthentication() + }.get(5, TimeUnit.SECONDS) + + then: + shared.getAuthentication().is(adminAuth) + SecurityContextHolder.getContext().getAuthentication().is(adminAuth) + seenByOtherThread.is(adminAuth) + + cleanup: + otherThread.shutdownNow() + SecurityContextHolder.clearContext() + } } diff --git a/jmix-flowui/flowui-starter/src/main/java/io/jmix/autoconfigure/flowui/FlowuiAutoConfiguration.java b/jmix-flowui/flowui-starter/src/main/java/io/jmix/autoconfigure/flowui/FlowuiAutoConfiguration.java index 77d6d4b8ad..2093aa9aac 100644 --- a/jmix-flowui/flowui-starter/src/main/java/io/jmix/autoconfigure/flowui/FlowuiAutoConfiguration.java +++ b/jmix-flowui/flowui-starter/src/main/java/io/jmix/autoconfigure/flowui/FlowuiAutoConfiguration.java @@ -23,6 +23,7 @@ import io.jmix.flowui.component.groupgrid.adapter.GroupDataGridAdapterFactory; import io.jmix.flowui.component.groupgrid.adapter.GroupDataGridAdapterProvider; import io.jmix.flowui.sys.ActionsConfiguration; +import io.jmix.flowui.sys.JmixSecurityContextHolderStrategy; import io.jmix.flowui.sys.UiAccessChecker; import io.jmix.flowui.sys.ViewControllersConfiguration; import io.jmix.flowui.sys.ViewSupport; @@ -38,11 +39,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Scope; +import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.jspecify.annotations.Nullable; import java.util.Collections; @@ -50,9 +53,23 @@ @AutoConfiguration +// Must be processed before Vaadin's SpringSecurityAutoConfiguration, so that its @ConditionalOnMissingBean +// SecurityContextHolderStrategy backs off in favor of the Jmix one defined below. +@AutoConfigureBefore(name = "com.vaadin.flow.spring.SpringSecurityAutoConfiguration") @Import({CoreConfiguration.class, FlowuiConfiguration.class}) public class FlowuiAutoConfiguration { + /** + * Replaces Vaadin's {@code VaadinAwareSecurityContextHolderStrategy} with a wrapper that lets + * {@link io.jmix.core.security.SystemAuthenticator} override the security context for the current thread only. + * Vaadin's {@code SpringSecurityAutoConfiguration} installs this bean into {@code SecurityContextHolder}. + */ + @Bean("flowui_SecurityContextHolderStrategy") + @ConditionalOnMissingBean(SecurityContextHolderStrategy.class) + public SecurityContextHolderStrategy securityContextHolderStrategy() { + return new JmixSecurityContextHolderStrategy(); + } + @Bean("jmix_AppUiControllers") @ConditionalOnMissingBean(name = "jmix_AppUiControllers") public ViewControllersConfiguration viewControllersConfiguration( diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/UiEventPublisher.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/UiEventPublisher.java index 5ace177382..f63a905b6c 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/UiEventPublisher.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/UiEventPublisher.java @@ -21,9 +21,11 @@ import com.vaadin.flow.component.page.Push; import com.vaadin.flow.server.VaadinSession; import com.vaadin.flow.server.VaadinSessionState; +import com.vaadin.flow.server.WrappedSession; import io.jmix.core.cluster.ClusterApplicationEvent; import io.jmix.core.cluster.ClusterApplicationEventPublisher; import io.jmix.core.security.CurrentAuthentication; +import io.jmix.core.security.SecurityContextHelper; import io.jmix.core.security.SystemAuthenticator; import io.jmix.core.usersubstitution.CurrentUserSubstitution; import io.jmix.flowui.sys.SessionHolder; @@ -34,6 +36,9 @@ import org.springframework.context.ApplicationEvent; import org.springframework.context.event.EventListener; import org.jspecify.annotations.Nullable; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.stereotype.Component; import java.util.*; @@ -96,21 +101,50 @@ protected void sendEventToUserSessions(ApplicationEvent event, Map> usernameSessionEntry : userSessions.entrySet()) { - // Without 'VaadinAwareSecurityContextHolderStrategyConfiguration' configuration - // when we get access to another user session, the security context is still the same as - // in VaadinSession of sender user. I.e. if "admin" send notification to "user1", the - // "CurrentAuthentication#getUser()" under VaadinSession of "user1" will return "admin". - - // To avoid the problem we should perform access to VaadinSession of recipient behalf of - // recipient. String sessionUsername = usernameSessionEntry.getKey(); List sessions = usernameSessionEntry.getValue(); for (VaadinSession session : sessions) { - systemAuthenticator.runWithUser(sessionUsername, - // obtain lock on session state - () -> session.access(() -> onSessionAccess(session, event))); + session.access(() -> { + if (session.getState() != VaadinSessionState.OPEN) { + return; + } + // Resolve and install the recipient's context when the queued callback actually runs. + SecurityContext recipientContext = getSessionSecurityContext(session); + if (recipientContext != null) { + SecurityContext previousContext = SecurityContextHolder.getContext(); + SecurityContextHelper.installContext(recipientContext); + try { + onSessionAccess(session, event); + } finally { + SecurityContextHelper.restoreContext(previousContext); + } + } else { + systemAuthenticator.runWithUser(sessionUsername, () -> onSessionAccess(session, event)); + } + }); + } + } + } + + /** + * Returns the security context stored in the HTTP session of the given Vaadin session, or null if there is none. + */ + @Nullable + protected SecurityContext getSessionSecurityContext(VaadinSession session) { + WrappedSession wrappedSession = session.getSession(); + if (wrappedSession == null) { + return null; + } + try { + Object attribute = wrappedSession.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY); + if (attribute instanceof SecurityContext securityContext && securityContext.getAuthentication() != null) { + return securityContext; } + } catch (IllegalStateException e) { + // the HTTP session is invalidated + log.debug("Cannot read security context of an invalidated session", e); } + return null; } protected void onSessionAccess(VaadinSession session, ApplicationEvent event) { diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/asynctask/DelegatingSecurityRunnable.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/asynctask/DelegatingSecurityRunnable.java index 0b8b9b31d8..2d790494d5 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/asynctask/DelegatingSecurityRunnable.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/asynctask/DelegatingSecurityRunnable.java @@ -18,6 +18,7 @@ import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextImpl; /** * Wraps a delegate {@link Runnable} with logic for setting up an {@link SecurityContext} before invoking the delegate @@ -32,7 +33,7 @@ public class DelegatingSecurityRunnable implements Runnable { private final SecurityContext securityContext; public DelegatingSecurityRunnable(Runnable delegate) { - this(delegate, SecurityContextHolder.getContext()); + this(delegate, copyOfCurrentContext()); } public DelegatingSecurityRunnable(Runnable delegate, SecurityContext securityContext) { @@ -47,7 +48,19 @@ public void run() { SecurityContextHolder.setContext(securityContext); delegate.run(); } finally { - SecurityContextHolder.setContext(originalSecurityContext); + if (SecurityContextHolder.createEmptyContext().equals(originalSecurityContext)) { + SecurityContextHolder.clearContext(); + } else { + SecurityContextHolder.setContext(originalSecurityContext); + } } } + + /** + * The current context instance may be shared with the HTTP session and other threads, so the delegate gets a + * copy holding the same {@link org.springframework.security.core.Authentication}. + */ + private static SecurityContext copyOfCurrentContext() { + return new SecurityContextImpl(SecurityContextHolder.getContext().getAuthentication()); + } } diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/asynctask/DelegatingSecuritySupplier.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/asynctask/DelegatingSecuritySupplier.java index 69895c5ff3..4502a24872 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/asynctask/DelegatingSecuritySupplier.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/asynctask/DelegatingSecuritySupplier.java @@ -18,6 +18,7 @@ import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextImpl; import java.util.function.Supplier; @@ -34,7 +35,7 @@ public class DelegatingSecuritySupplier implements Supplier { private final SecurityContext securityContext; public DelegatingSecuritySupplier(Supplier delegate) { - this(delegate, SecurityContextHolder.getContext()); + this(delegate, copyOfCurrentContext()); } public DelegatingSecuritySupplier(Supplier delegate, SecurityContext securityContext) { @@ -49,7 +50,19 @@ public T get() { SecurityContextHolder.setContext(securityContext); return delegate.get(); } finally { - SecurityContextHolder.setContext(originalSecurityContext); + if (SecurityContextHolder.createEmptyContext().equals(originalSecurityContext)) { + SecurityContextHolder.clearContext(); + } else { + SecurityContextHolder.setContext(originalSecurityContext); + } } } + + /** + * The current context instance may be shared with the HTTP session and other threads, so the delegate gets a + * copy holding the same {@link org.springframework.security.core.Authentication}. + */ + private static SecurityContext copyOfCurrentContext() { + return new SecurityContextImpl(SecurityContextHolder.getContext().getAuthentication()); + } } diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/backgroundtask/impl/BackgroundWorkerImpl.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/backgroundtask/impl/BackgroundWorkerImpl.java index b457e8324a..0511d713a4 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/backgroundtask/impl/BackgroundWorkerImpl.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/backgroundtask/impl/BackgroundWorkerImpl.java @@ -28,6 +28,7 @@ import io.jmix.core.impl.metadata.MetadataGenerationScope; import io.jmix.core.impl.session.ThreadLocalSessionData; import io.jmix.core.security.CurrentAuthentication; +import io.jmix.core.impl.logging.LogMdc; import io.jmix.core.security.SecurityContextHelper; import io.jmix.flowui.backgroundtask.*; import io.jmix.flowui.event.BackgroundTaskUnhandledExceptionEvent; @@ -40,6 +41,8 @@ import org.springframework.context.ApplicationEventPublisher; import org.jspecify.annotations.Nullable; import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import java.util.Arrays; @@ -200,9 +203,10 @@ private TaskExecutorImpl(UI ui, BackgroundTaskManager taskManager, BackgroundTas this.future = new FutureTask<>(this) { @Override protected void done() { - Authentication previousAuth = SecurityContextHelper.getAuthentication(); + // may run on the task thread or, on cancel, on a UI thread + SecurityContext previousContext = SecurityContextHolder.getContext(); - SecurityContextHelper.setAuthentication(authentication); + SecurityContextHelper.installContext(createSecurityContext()); ThreadLocalSessionData.setAttributes(sessionAttributes); ThreadLocalVaadinRequestHolder.setRequest(vaadinRequest); try { @@ -216,7 +220,7 @@ protected void done() { "to canceling task after session is invalidated"); cancelExecution(); } finally { - SecurityContextHelper.setAuthentication(previousAuth); + SecurityContextHelper.restoreContext(previousContext); ThreadLocalSessionData.clear(); ThreadLocalVaadinRequestHolder.clear(); } @@ -224,6 +228,15 @@ protected void done() { }; } + /** + * Creates a new context holding the authentication the task was started with. + */ + private SecurityContext createSecurityContext() { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + return context; + } + @Override public final V call() throws Exception { String threadName = Thread.currentThread().getName(); @@ -232,7 +245,10 @@ public final V call() throws Exception { Thread.currentThread().setName(THREAD_NAME_PREFIX + matcher.group(1) + "-" + username); } - SecurityContextHelper.setAuthentication(authentication); + // The task thread gets its own context instance; the one captured on the UI thread may be shared + // with the HTTP session and must not be modified. + SecurityContextHolder.setContext(createSecurityContext()); + LogMdc.setup(authentication); ThreadLocalSessionData.setAttributes(sessionAttributes); try { try (MetadataGenerationScope ignored = metadataGenerationManager.enter(metadataGeneration)) { @@ -265,7 +281,8 @@ public Map getParams() { }); } } finally { - SecurityContextHelper.setAuthentication(null); + SecurityContextHolder.clearContext(); + LogMdc.setup(null); ThreadLocalSessionData.clear(); } } @@ -274,8 +291,14 @@ public Map getParams() { @Override public final void handleProgress(T... changes) { ui.access(() -> { + // Progress handlers always run under the authentication the task was started with, regardless of + // the thread that executes the access command and of any SystemAuthenticator block in the task. + SecurityContext previousContext = SecurityContextHolder.getContext(); + SecurityContextHelper.installContext(createSecurityContext()); try (MetadataGenerationScope ignored = metadataGenerationManager.enter(metadataGeneration)) { process(Arrays.asList(changes)); + } finally { + SecurityContextHelper.restoreContext(previousContext); } }); } @@ -310,12 +333,12 @@ protected final void handleDone() { unregister(); // As "handleDone()" can be processed under BackgroundTask thread or under UI thread from which - // the task starts, we should save previous security context (that can be null) - // to restore it when "done()" is finished. - Authentication previousAuth = SecurityContextHelper.getAuthentication(); + // the task starts, we should save the previous security context to restore it when "done()" is finished. + // The previous context instance may be shared with the HTTP session and must not be modified. + SecurityContext previousContext = SecurityContextHolder.getContext(); try { - SecurityContextHelper.setAuthentication(authentication); + SecurityContextHelper.installContext(createSecurityContext()); V result = future.get(); @@ -344,7 +367,7 @@ protected final void handleDone() { } } } finally { - SecurityContextHelper.setAuthentication(previousAuth); + SecurityContextHelper.restoreContext(previousContext); if (finalizer != null) { finalizer.run(); diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/sys/JmixSecurityContextHolderStrategy.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/sys/JmixSecurityContextHolderStrategy.java new file mode 100644 index 0000000000..79e63db946 --- /dev/null +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/sys/JmixSecurityContextHolderStrategy.java @@ -0,0 +1,131 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.jmix.flowui.sys; + +import com.vaadin.flow.spring.security.VaadinAwareSecurityContextHolderStrategy; +import io.jmix.core.common.util.Preconditions; +import io.jmix.core.security.SystemAuthenticator; +import io.jmix.core.security.ThreadSecurityContextOverride; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolderStrategy; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.function.Supplier; + +/** + * {@link SecurityContextHolderStrategy} for FlowUI applications. + *

+ * Delegates to Vaadin's {@link VaadinAwareSecurityContextHolderStrategy}, which prefers the security context stored + * in the current Vaadin session over the thread-local one. On top of that, it lets {@link SystemAuthenticator} + * install a context that takes precedence for the current thread only, so that {@code begin()}/{@code end()} take + * effect on UI threads and never modify the context shared by all threads of the HTTP session. + *

+ * {@link #setContext(SecurityContext)} replaces the context within the current override, preserving the enclosing + * scopes. {@link #clearContext()} drops all overrides when a request or task releases its thread. + */ +@NullMarked +public class JmixSecurityContextHolderStrategy implements SecurityContextHolderStrategy, ThreadSecurityContextOverride { + + private final SecurityContextHolderStrategy delegate; + + private final ThreadLocal<@Nullable Deque> overrides = new ThreadLocal<>(); + + public JmixSecurityContextHolderStrategy() { + this(new VaadinAwareSecurityContextHolderStrategy()); + } + + public JmixSecurityContextHolderStrategy(SecurityContextHolderStrategy delegate) { + this.delegate = delegate; + } + + @Override + public SecurityContext getContext() { + Deque stack = overrides.get(); + if (stack != null && !stack.isEmpty()) { + return stack.getFirst(); + } + return delegate.getContext(); + } + + @Override + public Supplier getDeferredContext() { + Deque stack = overrides.get(); + if (stack != null && !stack.isEmpty()) { + SecurityContext context = stack.getFirst(); + return () -> context; + } + return delegate.getDeferredContext(); + } + + @Override + public void setContext(SecurityContext context) { + Preconditions.checkNotNullArgument(context); + Deque stack = overrides.get(); + if (stack != null && !stack.isEmpty()) { + stack.pop(); + stack.push(context); + } else { + delegate.setContext(context); + } + } + + @Override + public void setDeferredContext(Supplier deferredContext) { + Preconditions.checkNotNullArgument(deferredContext); + Deque stack = overrides.get(); + if (stack != null && !stack.isEmpty()) { + setContext(deferredContext.get()); + } else { + delegate.setDeferredContext(deferredContext); + } + } + + @Override + public void clearContext() { + overrides.remove(); + delegate.clearContext(); + } + + @Override + public SecurityContext createEmptyContext() { + return delegate.createEmptyContext(); + } + + @Override + public void pushContext(SecurityContext context) { + Deque stack = overrides.get(); + if (stack == null) { + stack = new ArrayDeque<>(); + overrides.set(stack); + } + stack.push(context); + } + + @Override + public void popContext() { + Deque stack = overrides.get(); + if (stack != null) { + stack.poll(); + if (stack.isEmpty()) { + overrides.remove(); + } + } + } +} diff --git a/jmix-flowui/flowui/src/test/groovy/asynctask/DelegatingSecurityWrappersTest.groovy b/jmix-flowui/flowui/src/test/groovy/asynctask/DelegatingSecurityWrappersTest.groovy new file mode 100644 index 0000000000..bd559c64a4 --- /dev/null +++ b/jmix-flowui/flowui/src/test/groovy/asynctask/DelegatingSecurityWrappersTest.groovy @@ -0,0 +1,79 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package asynctask + +import io.jmix.flowui.asynctask.DelegatingSecurityRunnable +import io.jmix.flowui.asynctask.DelegatingSecuritySupplier +import org.springframework.security.authentication.TestingAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.context.SecurityContextImpl +import spock.lang.Specification + +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class DelegatingSecurityWrappersTest extends Specification { + + Authentication userAuth = new TestingAuthenticationToken('user', 'pw') + Authentication otherAuth = new TestingAuthenticationToken('other', 'pw') + SecurityContext callerContext = new SecurityContextImpl(userAuth) + ExecutorService worker = Executors.newSingleThreadExecutor() + + def setup() { + SecurityContextHolder.setContext(callerContext) + } + + def cleanup() { + worker.shutdownNow() + SecurityContextHolder.clearContext() + } + + def "runnable runs with the caller's authentication but changes to the context stay on the worker thread"() { + given: + Authentication seen = null + def runnable = new DelegatingSecurityRunnable({ + seen = SecurityContextHolder.getContext().getAuthentication() + SecurityContextHolder.getContext().setAuthentication(otherAuth) + }) + + when: + worker.submit(runnable).get(5, TimeUnit.SECONDS) + + then: + seen.is(userAuth) + callerContext.getAuthentication().is(userAuth) + } + + def "supplier runs with the caller's authentication but changes to the context stay on the worker thread"() { + given: + def supplier = new DelegatingSecuritySupplier({ + Authentication seen = SecurityContextHolder.getContext().getAuthentication() + SecurityContextHolder.getContext().setAuthentication(otherAuth) + return seen + }) + + when: + Authentication seen = worker.submit({ supplier.get() } as java.util.concurrent.Callable).get(5, TimeUnit.SECONDS) + + then: + seen.is(userAuth) + callerContext.getAuthentication().is(userAuth) + } +} diff --git a/jmix-flowui/flowui/src/test/groovy/security_context/BackgroundTaskSecurityContextTest.groovy b/jmix-flowui/flowui/src/test/groovy/security_context/BackgroundTaskSecurityContextTest.groovy new file mode 100644 index 0000000000..799b24c7b2 --- /dev/null +++ b/jmix-flowui/flowui/src/test/groovy/security_context/BackgroundTaskSecurityContextTest.groovy @@ -0,0 +1,151 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package security_context + +import com.vaadin.flow.component.UI +import com.vaadin.flow.server.VaadinSession +import io.jmix.core.security.SystemAuthenticationToken +import io.jmix.flowui.backgroundtask.BackgroundTask +import io.jmix.flowui.backgroundtask.BackgroundWorker +import io.jmix.flowui.backgroundtask.TaskLifeCycle +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContextHolder + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * Background tasks are started from a UI thread of the 'admin' session. The task runs on a worker thread, + * while progress and done handlers run through {@code ui.access()} inside the session. + */ +@SpringBootTest +class BackgroundTaskSecurityContextTest extends SessionSecurityContextSpecification { + + @Autowired + BackgroundWorker backgroundWorker + + VaadinSession taskSession + UI taskUi + + void setup() { + taskSession = createSessionWithImmediateAccess(sessionContext) + taskUi = createUi(taskSession, 3) + VaadinSession.setCurrent(taskSession) + UI.setCurrent(taskUi) + } + + def "task body runs under the user's authentication and withSystem inside the task does not affect the session"() { + given: + def bodyAuth = new AtomicReference() + def insideSystemAuth = new AtomicReference() + def sessionAuthDuringSystem = new AtomicReference() + def done = new CountDownLatch(1) + def task = new BackgroundTask(10) { + @Override + Void run(TaskLifeCycle lifeCycle) { + bodyAuth.set(SecurityContextHolder.getContext().getAuthentication()) + systemAuthenticator.runWithSystem { + insideSystemAuth.set(SecurityContextHolder.getContext().getAuthentication()) + sessionAuthDuringSystem.set(sessionContext.getAuthentication()) + } + return null + } + + @Override + void done(Void result) { + done.countDown() + } + } + + when: + backgroundWorker.handle(task).execute() + + then: + done.await(10, TimeUnit.SECONDS) + bodyAuth.get().is(adminAuth) + insideSystemAuth.get() instanceof SystemAuthenticationToken + sessionAuthDuringSystem.get().is(adminAuth) + } + + def "progress handler runs under the user's authentication even when published from inside withSystem"() { + given: + def progressAuth = new AtomicReference() + def done = new CountDownLatch(1) + def task = new BackgroundTask(10) { + @Override + Void run(TaskLifeCycle lifeCycle) { + systemAuthenticator.runWithSystem { + lifeCycle.publish(1) + } + return null + } + + @Override + void progress(List changes) { + progressAuth.set(SecurityContextHolder.getContext().getAuthentication()) + } + + @Override + void done(Void result) { + done.countDown() + } + } + + when: + backgroundWorker.handle(task).execute() + + then: + done.await(10, TimeUnit.SECONDS) + progressAuth.get().is(adminAuth) + } + + def "done handler does not modify the session security context when the task was started under withSystem"() { + given: + def doneAuth = new AtomicReference() + def sessionAuthDuringDone = new AtomicReference() + def done = new CountDownLatch(1) + def task = new BackgroundTask(10) { + @Override + Void run(TaskLifeCycle lifeCycle) { + return null + } + + @Override + void done(Void result) { + doneAuth.set(SecurityContextHolder.getContext().getAuthentication()) + sessionAuthDuringDone.set(sessionContext.getAuthentication()) + done.countDown() + } + } + + when: "the task is started from a system block on the UI thread" + systemAuthenticator.runWithSystem { + backgroundWorker.handle(task).execute() + } + + then: "the done handler runs under the authentication the task was started with" + done.await(10, TimeUnit.SECONDS) + doneAuth.get() instanceof SystemAuthenticationToken + + and: "the session's shared context is never modified" + sessionAuthDuringDone.get().is(adminAuth) + sessionContext.getAuthentication().is(adminAuth) + } +} diff --git a/jmix-flowui/flowui/src/test/groovy/security_context/JmixSecurityContextHolderStrategyTest.groovy b/jmix-flowui/flowui/src/test/groovy/security_context/JmixSecurityContextHolderStrategyTest.groovy new file mode 100644 index 0000000000..f5d37dac69 --- /dev/null +++ b/jmix-flowui/flowui/src/test/groovy/security_context/JmixSecurityContextHolderStrategyTest.groovy @@ -0,0 +1,341 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package security_context + +import com.vaadin.flow.server.VaadinSession +import io.jmix.core.security.SystemAuthenticationToken +import io.jmix.flowui.UiEventPublisher +import io.jmix.flowui.asynctask.DelegatingSecurityRunnable +import io.jmix.flowui.asynctask.DelegatingSecuritySupplier +import io.jmix.flowui.sys.event.UiEventsManager +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationListener +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.concurrent.DelegatingSecurityContextRunnable +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.context.SecurityContextImpl +import org.springframework.security.core.userdetails.User +import ui_events.TestUiEvent + +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +@SpringBootTest +class JmixSecurityContextHolderStrategyTest extends SessionSecurityContextSpecification { + + @Autowired + UiEventPublisher uiEventPublisher + + def "on a UI thread the session context is current"() { + expect: + VaadinSession.getCurrent().is(vaadinSession) + SecurityContextHolder.getContext().is(sessionContext) + } + + def "withSystem takes effect on a UI thread and leaves the session context untouched"() { + when: + Authentication inside = systemAuthenticator.withSystem { + SecurityContextHolder.getContext().getAuthentication() + } + + then: + inside instanceof SystemAuthenticationToken + inside.getName() == 'system' + sessionContext.getAuthentication().is(adminAuth) + SecurityContextHolder.getContext().is(sessionContext) + } + + def "begin on a UI thread is not visible to another thread of the same session"() { + given: + ExecutorService otherThread = Executors.newSingleThreadExecutor() + + when: + systemAuthenticator.begin() + Authentication seenByOtherThread = otherThread.submit { + VaadinSession.setCurrent(vaadinSession) + try { + return SecurityContextHolder.getContext().getAuthentication() + } finally { + VaadinSession.setCurrent(null) + } + }.get(5, TimeUnit.SECONDS) + + then: + seenByOtherThread.is(adminAuth) + + cleanup: + systemAuthenticator.end() + otherThread.shutdownNow() + } + + def "clearContext removes an override left by begin without end"() { + when: + systemAuthenticator.begin() + SecurityContextHolder.clearContext() + + then: + SecurityContextHolder.getContext().getAuthentication().is(adminAuth) + + when: "the late end does not throw" + systemAuthenticator.end() + + then: + SecurityContextHolder.getContext().getAuthentication().is(adminAuth) + } + + def "without a Vaadin session the strategy behaves as a thread-local strategy"() { + given: + VaadinSession.setCurrent(null) + SecurityContext threadContext = new SecurityContextImpl(adminAuth) + SecurityContextHolder.setContext(threadContext) + + when: + Authentication inside = systemAuthenticator.withSystem { + SecurityContextHolder.getContext().getAuthentication() + } + + then: + inside instanceof SystemAuthenticationToken + SecurityContextHolder.getContext().is(threadContext) + threadContext.getAuthentication().is(adminAuth) + } + + def "UI event handlers of a recipient session run under that session's own authentication"() { + given: + def recipient = recipientSession() + def event = new TestUiEvent(this, "eventMessage") + + when: "the event is sent from the admin session" + uiEventPublisher.sendEventToUserSessions(event, ['recipient': [recipient.session]]) + + then: "the handler ran under the recipient's own session authentication" + recipient.handlerAuthentication.is(recipient.auth) + + and: "the sender's context was not modified" + sessionContext.getAuthentication().is(adminAuth) + SecurityContextHolder.getContext().is(sessionContext) + + cleanup: + userRepository.removeUser(recipient.user) + } + + def "UI event handlers of a recipient session run under that session's own authentication when sent from withSystem"() { + given: + def recipient = recipientSession() + def event = new TestUiEvent(this, "eventMessage") + + when: "the event is sent from a system block, as a scheduled job would do" + systemAuthenticator.runWithSystem { + uiEventPublisher.sendEventToUserSessions(event, ['recipient': [recipient.session]]) + } + + then: + recipient.handlerAuthentication.is(recipient.auth) + SecurityContextHolder.getContext().is(sessionContext) + + cleanup: + userRepository.removeUser(recipient.user) + } + + def "test nested #wrapper preserves system authentication and restores the caller (UI session: #withSession)"() { + given: + if (!withSession) { + VaadinSession.setCurrent(null) + SecurityContextHolder.setContext(sessionContext) + } + Authentication inside = null + Authentication afterWrapper = null + + when: + systemAuthenticator.runWithSystem { + Runnable action = { inside = SecurityContextHolder.getContext().authentication } + switch (wrapper) { + case 'runnable': + new DelegatingSecurityRunnable(action).run() + break + case 'supplier': + new DelegatingSecuritySupplier({ action.run() }).get() + break + case 'Spring runnable': + new DelegatingSecurityContextRunnable(action).run() + break + } + afterWrapper = SecurityContextHolder.getContext().authentication + } + + then: + inside instanceof SystemAuthenticationToken + afterWrapper.is(inside) + SecurityContextHolder.getContext().is(sessionContext) + sessionContext.authentication.is(adminAuth) + + where: + [wrapper, withSession] << [['runnable', 'supplier', 'Spring runnable'], [true, false]].combinations() + } + + def "test queued UI event uses recipient authentication when processed inside withSystem"() { + given: + def recipient = recipientSession(false) + def worker = Executors.newSingleThreadExecutor() + VaadinSession.setCurrent(null) + recipient.session.lock() + + when: + worker.submit { + uiEventPublisher.sendEventToUserSessions(new TestUiEvent(this, 'queued'), + ['recipient': [recipient.session]]) + }.get(5, TimeUnit.SECONDS) + + then: + recipient.handlerAuthentication == null + + when: + systemAuthenticator.runWithSystem { + recipient.session.unlock() + assert SecurityContextHolder.getContext().authentication instanceof SystemAuthenticationToken + } + + then: + recipient.handlerAuthentication.is(recipient.auth) + + cleanup: + if (recipient.session.hasLock()) { + recipient.session.unlock() + } + worker.shutdownNow() + userRepository.removeUser(recipient.user) + } + + def "test explicit wrapper context restores nested authentication after an exception"() { + when: + systemAuthenticator.runWithSystem { + SecurityContext systemContext = SecurityContextHolder.getContext() + systemAuthenticator.runWithUser('admin') { + SecurityContext userContext = SecurityContextHolder.getContext() + try { + new DelegatingSecurityRunnable({ + assert SecurityContextHolder.getContext().is(sessionContext) + throw new IllegalStateException('task failed') + }, sessionContext).run() + assert false: 'The task must throw' + } catch (IllegalStateException ignored) { + assert SecurityContextHolder.getContext().is(userContext) + } + } + assert SecurityContextHolder.getContext().is(systemContext) + } + + then: + SecurityContextHolder.getContext().is(sessionContext) + sessionContext.authentication.is(adminAuth) + } + + def "test deferred context replacement preserves the enclosing authentication scope"() { + given: + VaadinSession.setCurrent(null) + SecurityContextHolder.setContext(sessionContext) + + when: + systemAuthenticator.runWithSystem { + def previous = SecurityContextHolder.getDeferredContext() + try { + SecurityContextHolder.setDeferredContext { sessionContext } + assert SecurityContextHolder.getContext().is(sessionContext) + } finally { + SecurityContextHolder.setDeferredContext(previous) + } + assert SecurityContextHolder.getContext().authentication instanceof SystemAuthenticationToken + } + + then: + SecurityContextHolder.getContext().is(sessionContext) + } + + def "test #wrapper clears unfinished system scopes on a pooled thread"() { + given: + ExecutorService worker = Executors.newSingleThreadExecutor() + Runnable action = { + systemAuthenticator.begin() + systemAuthenticator.begin() + } + def task = wrapper == 'runnable' ? new DelegatingSecurityRunnable(action) : + new DelegatingSecuritySupplier({ action.run() }) + + when: + worker.submit { + if (task instanceof Runnable) { + task.run() + } else { + task.get() + } + }.get(5, TimeUnit.SECONDS) + Authentication afterLateEnd = worker.submit { + systemAuthenticator.end() + return SecurityContextHolder.getContext().authentication + }.get(5, TimeUnit.SECONDS) + + then: + afterLateEnd == null + sessionContext.authentication.is(adminAuth) + + cleanup: + worker.submit { + systemAuthenticator.end() + SecurityContextHolder.clearContext() + }.get(5, TimeUnit.SECONDS) + worker.shutdownNow() + + where: + wrapper << ['runnable', 'supplier'] + } + + /** + * A recipient session whose HTTP session holds the recipient's security context, with one UI and an event + * listener that records the authentication it ran under. + */ + private RecipientSession recipientSession(boolean immediateAccess = true) { + def result = new RecipientSession() + result.user = User.builder().username('recipient').password('').authorities(Collections.emptyList()).build() + result.auth = new UsernamePasswordAuthenticationToken(result.user, null, result.user.authorities) + userRepository.addUser(result.user) + + def context = new SecurityContextImpl(result.auth) + result.session = immediateAccess ? createSessionWithImmediateAccess(context) : createSessionWithLock(context) + result.session.lock() + try { + result.session.setAttribute(UiEventsManager, new UiEventsManager()) + def recipientUi = createUi(result.session, 2) + result.session.getAttribute(UiEventsManager).addApplicationListener(recipientUi, { e -> + result.handlerAuthentication = SecurityContextHolder.getContext().getAuthentication() + } as ApplicationListener) + } finally { + result.session.unlock() + } + return result + } + + private static class RecipientSession { + VaadinSession session + User user + Authentication auth + Authentication handlerAuthentication + } +} diff --git a/jmix-flowui/flowui/src/test/groovy/security_context/SessionSecurityContextSpecification.groovy b/jmix-flowui/flowui/src/test/groovy/security_context/SessionSecurityContextSpecification.groovy new file mode 100644 index 0000000000..7e0a592c43 --- /dev/null +++ b/jmix-flowui/flowui/src/test/groovy/security_context/SessionSecurityContextSpecification.groovy @@ -0,0 +1,138 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package security_context + +import com.vaadin.flow.component.UI +import com.vaadin.flow.server.VaadinService +import com.vaadin.flow.server.VaadinSession +import com.vaadin.flow.server.WrappedHttpSession +import io.jmix.core.security.InMemoryUserRepository +import io.jmix.flowui.backgroundtask.BackgroundTaskManager +import io.jmix.flowui.sys.JmixSecurityContextHolderStrategy +import io.jmix.flowui.sys.event.UiEventsManager +import io.jmix.flowui.testassist.vaadin.TestUI +import io.jmix.flowui.testassist.vaadin.TestVaadinRequest +import io.jmix.flowui.testassist.vaadin.TestVaadinSession +import org.apache.commons.lang3.reflect.FieldUtils +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.mock.web.MockHttpSession +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.context.SecurityContextImpl +import org.springframework.security.core.userdetails.User +import org.springframework.security.web.context.HttpSessionSecurityContextRepository +import test_support.spec.FlowuiTestSpecification + +import java.util.concurrent.locks.Lock +import java.util.concurrent.locks.ReentrantLock + +/** + * Runs with {@link JmixSecurityContextHolderStrategy} installed, as in a FlowUI application with Spring Security, + * and with a Vaadin session whose HTTP session holds the security context of the logged-in user 'admin'. + */ +abstract class SessionSecurityContextSpecification extends FlowuiTestSpecification { + + @Autowired + InMemoryUserRepository userRepository + + User admin + Authentication adminAuth + SecurityContext sessionContext + + @Override + protected void setupAuthentication() { + SecurityContextHolder.setContextHolderStrategy(new JmixSecurityContextHolderStrategy()) + } + + @Override + protected void removeAuthentication() { + SecurityContextHolder.clearContext() + SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL) + } + + void setup() { + admin = User.builder().username('admin').password('').authorities(Collections.emptyList()).build() + userRepository.addUser(admin) + adminAuth = new UsernamePasswordAuthenticationToken(admin, null, admin.authorities) + sessionContext = new SecurityContextImpl(adminAuth) + bindHttpSession(vaadinSession, sessionContext) + } + + void cleanup() { + userRepository.removeUser(admin) + VaadinSession.setCurrent(vaadinSession) + UI.setCurrent(ui) + } + + /** + * Gives the Vaadin session an HTTP session that holds the given security context under the key used by + * Spring Security, so that the Vaadin-aware strategy finds it. + */ + protected static void bindHttpSession(VaadinSession session, SecurityContext securityContext) { + def httpSession = new MockHttpSession() + httpSession.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, securityContext) + // VaadinSession.refreshTransients() asserts that the session lock is held, which the test session + // does not model, so the wrapped session is set directly. + FieldUtils.writeField(session, "session", new WrappedHttpSession(httpSession), true) + } + + /** + * Creates a Vaadin session that runs tasks queued by {@code session.access()} as soon as the lock is released, + * as a real session does. {@link TestVaadinSession#unlock()} is a no-op, so such tasks would never run there. + */ + protected VaadinSession createSessionWithImmediateAccess(SecurityContext securityContext) { + VaadinService service = vaadinSession.getService() + VaadinSession session = new TestVaadinSession(service) { + @Override + void unlock() { + service.runPendingAccessTasks(this) + } + } + bindHttpSession(session, securityContext) + session.setAttribute(BackgroundTaskManager, new BackgroundTaskManager()) + session.setAttribute(UiEventsManager, new UiEventsManager()) + return session + } + + protected UI createUi(VaadinSession session, int uiId) { + def newUi = new TestUI() + newUi.getInternals().setSession(session) + newUi.doInit(new TestVaadinRequest(session.getService()), uiId, "testAppId" + uiId) + session.addUI(newUi) + return newUi + } + + protected VaadinSession createSessionWithLock(SecurityContext securityContext) { + VaadinSession session = new VaadinSession(vaadinSession.getService()) { + private final ReentrantLock sessionLock = new ReentrantLock() + + @Override + Lock getLockInstance() { + return sessionLock + } + + @Override + boolean hasLock() { + return sessionLock.isHeldByCurrentThread() + } + } + bindHttpSession(session, securityContext) + return session + } +} diff --git a/jmix-reports/reports/src/main/java/io/jmix/reports/libintegration/JmixOfficeIntegration.java b/jmix-reports/reports/src/main/java/io/jmix/reports/libintegration/JmixOfficeIntegration.java index e2be44d89e..f181bebc99 100644 --- a/jmix-reports/reports/src/main/java/io/jmix/reports/libintegration/JmixOfficeIntegration.java +++ b/jmix-reports/reports/src/main/java/io/jmix/reports/libintegration/JmixOfficeIntegration.java @@ -24,6 +24,7 @@ import com.sun.star.comp.helper.BootstrapException; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextImpl; import jakarta.annotation.PreDestroy; import java.util.List; @@ -41,15 +42,20 @@ public JmixOfficeIntegration(String openOfficePath, List ports) { @Override public void runTaskWithTimeout(final OfficeTask officeTask, int timeoutInSeconds) throws NoFreePortsException { - final SecurityContext securityContext = SecurityContextHolder.getContext(); + // the current context instance may be shared with the HTTP session, so the task gets a copy + final SecurityContext securityContext = + new SecurityContextImpl(SecurityContextHolder.getContext().getAuthentication()); final OfficeConnection connection = acquireConnection(); Future future = null; try { Callable task = () -> { SecurityContextHolder.setContext(securityContext); - connection.open(); - officeTask.processTaskInOpenOffice(connection.getOOResourceProvider()); - SecurityContextHolder.clearContext(); + try { + connection.open(); + officeTask.processTaskInOpenOffice(connection.getOOResourceProvider()); + } finally { + SecurityContextHolder.clearContext(); + } return null; }; future = executor.submit(task);