Skip to content
Merged
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 @@ -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;

Expand All @@ -44,6 +45,10 @@ public static Authentication getAuthentication() {

/**
* Sets the Authentication in the current {@link SecurityContext}.
* <p>
* 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) {
Expand All @@ -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.
* <p>
* Must be paired with {@link #restoreContext(SecurityContext)} in a "finally" block:
* <pre>
* SecurityContext previous = SecurityContextHolder.getContext();
* SecurityContextHelper.installContext(context);
* try {
* // ...
* } finally {
* SecurityContextHelper.restoreContext(previous);
* }
* </pre>
*/
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);
}
}
Original file line number Diff line number Diff line change
@@ -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).
* <p>
* 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.
* <p>
* 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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand All @@ -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;
}
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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<Deque<Authentication>> threadLocalStack = new ThreadLocal<>();
protected ThreadLocal<Deque<SecurityContext>> threadLocalStack = new ThreadLocal<>();

public SystemAuthenticatorSupport() {
}

protected void pushAuthentication(@Nullable Authentication authentication) {
Deque<Authentication> stack = threadLocalStack.get();
protected void pushSecurityContext(SecurityContext securityContext) {
Deque<SecurityContext> stack = threadLocalStack.get();
if (stack == null) {
stack = new ArrayDeque<>();
threadLocalStack.set(stack);
Expand All @@ -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<Authentication> stack = threadLocalStack.get();
protected SecurityContext pollSecurityContext() {
Deque<SecurityContext> 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.");
}
Expand All @@ -72,14 +67,4 @@ protected Authentication pollAuthentication() {
}
return null;
}

protected static class NullAuthentication extends SystemAuthenticationToken {

private static final long serialVersionUID = 5437664860036209641L;

public NullAuthentication() {
super();
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,20 @@ 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
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 {

Expand Down Expand Up @@ -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()
}
}
Loading