Split into per-article modules and add the method-security module
Moves the existing virtual-thread/context-propagation project into context-propagation/ and adds method-security/ for the Spring Security 7 method-security article: nine runnable demos, fourteen assertions, and every transcript the article quotes, regenerated by scripts/run-all.sh. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSrsDSRKVsY588yFiMJMo9
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
package com.ankurm.vt;
|
||||
|
||||
// Explained in docs/01-inheritable-threadlocal.md -- run via scripts/run-all.sh, output captured in docs/output/
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Confirms, with no Spring involved, how InheritableThreadLocal behaves for
|
||||
* (a) a fresh platform Thread, (b) a reused thread from a fixed pool, and
|
||||
* (c) a fresh virtual thread. */
|
||||
public class Demo1PlainThreadLocal {
|
||||
|
||||
static final InheritableThreadLocal<String> CTX = new InheritableThreadLocal<>();
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
System.out.println("=== Demo 1: InheritableThreadLocal across thread models ===");
|
||||
|
||||
// (a) fresh platform Thread inherits at construction time
|
||||
CTX.set("request-A");
|
||||
Thread t = new Thread(() -> System.out.println("fresh platform thread sees: " + CTX.get()));
|
||||
t.start();
|
||||
t.join();
|
||||
|
||||
// (b) reused thread from a fixed pool: the SECOND task on the same worker
|
||||
// still carries whatever was set when the pool thread was originally created
|
||||
ExecutorService pool = Executors.newFixedThreadPool(1);
|
||||
CTX.set("request-B");
|
||||
pool.submit(() -> System.out.println("pool thread, task 1, sees: " + CTX.get())).get();
|
||||
CTX.set("request-C"); // caller's context changed
|
||||
pool.submit(() -> System.out.println("pool thread, task 2 (reused), sees: " + CTX.get()
|
||||
+ " <-- stale, not request-C")).get();
|
||||
pool.shutdown();
|
||||
|
||||
// (c) fresh virtual thread, never reused
|
||||
CTX.set("request-D");
|
||||
Thread vt = Thread.ofVirtual().start(() ->
|
||||
System.out.println("fresh virtual thread sees: " + CTX.get()));
|
||||
vt.join();
|
||||
|
||||
CTX.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.ankurm.vt;
|
||||
|
||||
// Explained in docs/02-async-virtual-threads.md -- run via scripts/run-all.sh, output captured in docs/output/
|
||||
|
||||
import org.springframework.core.task.AsyncTaskExecutor;
|
||||
import org.springframework.core.task.SimpleAsyncTaskExecutor;
|
||||
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
|
||||
import org.springframework.security.concurrent.DelegatingSecurityContextExecutor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.context.SecurityContextHolderThreadLocalAccessor;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Reproduces the exact bean shape Boot 4.1 creates when
|
||||
* spring.threads.virtual.enabled=true: a SimpleAsyncTaskExecutor backed by
|
||||
* Thread.ofVirtual(). Shows what SecurityContextHolder.MODE_THREADLOCAL (the
|
||||
* Spring Security default) does and does not propagate into it, and what
|
||||
* three different fixes change. */
|
||||
public class Demo2AsyncVirtualThreads {
|
||||
|
||||
static SimpleAsyncTaskExecutor bootStyleVirtualThreadExecutor() {
|
||||
SimpleAsyncTaskExecutor exec = new SimpleAsyncTaskExecutor("vt-");
|
||||
exec.setVirtualThreads(true); // what spring.threads.virtual.enabled=true wires up
|
||||
return exec;
|
||||
}
|
||||
|
||||
static Authentication auth(String name) {
|
||||
return UsernamePasswordAuthenticationToken.authenticated(
|
||||
name, "n/a", AuthorityUtils.createAuthorityList("ROLE_USER"));
|
||||
}
|
||||
|
||||
static void run(String label, Executor executor) throws InterruptedException {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
executor.execute(() -> {
|
||||
Authentication a = SecurityContextHolder.getContext().getAuthentication();
|
||||
System.out.println(label + ": " + (a == null ? "NO AUTHENTICATION (lost)" : "authenticated as " + a.getName())
|
||||
+ " [" + Thread.currentThread() + "]");
|
||||
latch.countDown();
|
||||
});
|
||||
latch.await(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
System.out.println("=== Demo 2: @Async-style virtual thread executor + SecurityContext ===");
|
||||
|
||||
// --- Scenario A: default MODE_THREADLOCAL, unwrapped virtual-thread executor ---
|
||||
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL);
|
||||
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
|
||||
ctx.setAuthentication(auth("alice"));
|
||||
SecurityContextHolder.setContext(ctx);
|
||||
run("A) MODE_THREADLOCAL, raw SimpleAsyncTaskExecutor(virtual)", bootStyleVirtualThreadExecutor());
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
// --- Scenario B: MODE_INHERITABLETHREADLOCAL, same raw executor ---
|
||||
// The historical warning against this mode is about REUSED pool threads.
|
||||
// SimpleAsyncTaskExecutor with virtual threads never reuses a thread, so
|
||||
// the usual danger doesn't apply here -- verifying that directly.
|
||||
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
|
||||
ctx = SecurityContextHolder.createEmptyContext();
|
||||
ctx.setAuthentication(auth("bob"));
|
||||
SecurityContextHolder.setContext(ctx);
|
||||
run("B) MODE_INHERITABLETHREADLOCAL, raw SimpleAsyncTaskExecutor(virtual)", bootStyleVirtualThreadExecutor());
|
||||
SecurityContextHolder.clearContext();
|
||||
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL); // reset default
|
||||
|
||||
// --- Scenario C: DelegatingSecurityContextExecutor wrapping the virtual-thread executor ---
|
||||
ctx = SecurityContextHolder.createEmptyContext();
|
||||
ctx.setAuthentication(auth("carol"));
|
||||
SecurityContextHolder.setContext(ctx);
|
||||
Executor wrapped = new DelegatingSecurityContextExecutor(bootStyleVirtualThreadExecutor());
|
||||
run("C) DelegatingSecurityContextExecutor around SimpleAsyncTaskExecutor(virtual)", wrapped);
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
// --- Scenario D: ContextPropagatingTaskDecorator + SecurityContextHolderThreadLocalAccessor ---
|
||||
// Confirms the accessor is really registered with Micrometer's ContextRegistry
|
||||
// (it self-registers via ServiceLoader when context-propagation is on the classpath).
|
||||
System.out.println("SecurityContextHolderThreadLocalAccessor present: "
|
||||
+ (new SecurityContextHolderThreadLocalAccessor() != null));
|
||||
SimpleAsyncTaskExecutor decorated = bootStyleVirtualThreadExecutor();
|
||||
decorated.setTaskDecorator(new ContextPropagatingTaskDecorator());
|
||||
ctx = SecurityContextHolder.createEmptyContext();
|
||||
ctx.setAuthentication(auth("dave"));
|
||||
SecurityContextHolder.setContext(ctx);
|
||||
run("D) ContextPropagatingTaskDecorator on SimpleAsyncTaskExecutor(virtual), no Delegating* wrapper", decorated);
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.ankurm.vt;
|
||||
|
||||
// Explained in docs/03-structured-concurrency.md -- run via scripts/run-all.sh, output captured in docs/output/
|
||||
|
||||
import io.micrometer.context.ContextSnapshot;
|
||||
import io.micrometer.context.ContextSnapshotFactory;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.StructuredTaskScope;
|
||||
import java.util.concurrent.StructuredTaskScope.Subtask;
|
||||
|
||||
/** Does a StructuredTaskScope subtask (a fresh virtual thread) see the parent's
|
||||
* SecurityContext? Four scenarios, same question each time. Requires
|
||||
* --enable-preview on JDK 25 (StructuredTaskScope is JEP 505, fifth preview). */
|
||||
public class Demo3StructuredConcurrency {
|
||||
|
||||
static Authentication auth(String name) {
|
||||
return UsernamePasswordAuthenticationToken.authenticated(
|
||||
name, "n/a", AuthorityUtils.createAuthorityList("ROLE_USER"));
|
||||
}
|
||||
|
||||
static void setAuth(String name) {
|
||||
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
|
||||
ctx.setAuthentication(auth(name));
|
||||
SecurityContextHolder.setContext(ctx);
|
||||
}
|
||||
|
||||
static String readAuthInSubtask() {
|
||||
Authentication a = SecurityContextHolder.getContext().getAuthentication();
|
||||
return a == null ? "NO AUTHENTICATION (lost)" : "authenticated as " + a.getName();
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
System.out.println("=== Demo 3: StructuredTaskScope.fork() + SecurityContext ===");
|
||||
|
||||
// A) default MODE_THREADLOCAL, plain fork
|
||||
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL);
|
||||
setAuth("erin");
|
||||
try (var scope = StructuredTaskScope.open()) {
|
||||
Subtask<String> s = scope.fork(() -> "A) plain fork, MODE_THREADLOCAL: " + readAuthInSubtask());
|
||||
scope.join();
|
||||
System.out.println(s.get());
|
||||
}
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
// B) MODE_INHERITABLETHREADLOCAL, plain fork
|
||||
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
|
||||
setAuth("frank");
|
||||
try (var scope = StructuredTaskScope.open()) {
|
||||
Subtask<String> s = scope.fork(() -> "B) plain fork, MODE_INHERITABLETHREADLOCAL: " + readAuthInSubtask());
|
||||
scope.join();
|
||||
System.out.println(s.get());
|
||||
}
|
||||
SecurityContextHolder.clearContext();
|
||||
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL); // reset
|
||||
|
||||
// C) manual capture-and-restore around the forked Callable (portable, no reliance on mode)
|
||||
setAuth("grace");
|
||||
SecurityContext captured = SecurityContextHolder.getContext();
|
||||
try (var scope = StructuredTaskScope.open()) {
|
||||
Callable<String> task = () -> {
|
||||
SecurityContextHolder.setContext(captured);
|
||||
try {
|
||||
return "C) manual capture/restore: " + readAuthInSubtask();
|
||||
} finally {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
};
|
||||
Subtask<String> s = scope.fork(task);
|
||||
scope.join();
|
||||
System.out.println(s.get());
|
||||
}
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
// D) Micrometer ContextSnapshot wrap (uses SecurityContextHolderThreadLocalAccessor)
|
||||
setAuth("heidi");
|
||||
ContextSnapshot snapshot = ContextSnapshotFactory.builder().build().captureAll();
|
||||
try (var scope = StructuredTaskScope.open()) {
|
||||
Callable<String> task = snapshot.wrap(
|
||||
(Callable<String>) () -> "D) ContextSnapshot.wrap: " + readAuthInSubtask());
|
||||
Subtask<String> s = scope.fork(task);
|
||||
scope.join();
|
||||
System.out.println(s.get());
|
||||
}
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.ankurm.vt;
|
||||
|
||||
// Explained in docs/04-executor-wrapping.md -- run via scripts/run-all.sh, output captured in docs/output/
|
||||
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.security.concurrent.DelegatingSecurityContextExecutor;
|
||||
import org.springframework.security.concurrent.DelegatingSecurityContextExecutorService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.task.DelegatingSecurityContextAsyncTaskExecutor;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* The pre-virtual-thread baseline this guide's "Using @Async" / "Using ExecutorService" /
|
||||
* "Using CompletableFuture" sections describe: a fixed {@code ThreadPoolExecutor} whose
|
||||
* workers are constructed once and reused for every task after that -- exactly the
|
||||
* pooled-thread shape {@link Demo1PlainThreadLocal} showed going stale under
|
||||
* {@code MODE_INHERITABLETHREADLOCAL}.
|
||||
*
|
||||
* <p>The point of this demo is the contrast Chapter 1 sets up but doesn't resolve: the
|
||||
* {@code Delegating*} wrapper classes solve the exact staleness problem Chapter 1 found,
|
||||
* but by a completely different mechanism. They capture the {@code SecurityContext} once,
|
||||
* at wrapper-construction time (not at pool-worker-construction time, and not by thread
|
||||
* inheritance at all), and push/pop it around each task's {@code run()}/{@code call()} on
|
||||
* whichever thread actually executes it. A reused pool worker is irrelevant to them.
|
||||
*
|
||||
* <p>Four scenarios: raw pool (loses context, and later tasks race whichever context is
|
||||
* active at submission time -- see the caveat printed for scenario A), then the three
|
||||
* {@code Delegating*} classes named in the post's async section.
|
||||
*/
|
||||
public class Demo4ExecutorWrapping {
|
||||
|
||||
static Authentication auth(String name) {
|
||||
return UsernamePasswordAuthenticationToken.authenticated(
|
||||
name, "n/a", AuthorityUtils.createAuthorityList("ROLE_USER"));
|
||||
}
|
||||
|
||||
static void setAuth(String name) {
|
||||
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
|
||||
ctx.setAuthentication(auth(name));
|
||||
SecurityContextHolder.setContext(ctx);
|
||||
}
|
||||
|
||||
static String describe(Authentication a) {
|
||||
return a == null ? "NO AUTHENTICATION (lost)" : "authenticated as " + a.getName();
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
System.out.println("=== Demo 4: Executor/ExecutorService/AsyncTaskExecutor wrapping on a pooled platform thread ===");
|
||||
|
||||
// --- Scenario A: raw fixed ThreadPoolExecutor, no wrapper -- context lost per task ---
|
||||
ThreadPoolExecutor rawPool = new ThreadPoolExecutor(
|
||||
2, 2, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
|
||||
setAuth("alice");
|
||||
run("A) raw ThreadPoolExecutor, no wrapper", rawPool);
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
// --- Scenario B: DelegatingSecurityContextExecutorService wraps the whole ExecutorService ---
|
||||
// Matches the post's TaskExecutionService pattern (a class literally named
|
||||
// "ExecutorService" with a same-named-as-field constructor never compiled in the
|
||||
// original draft; fixed here and named for what it does).
|
||||
ExecutorService wrappedService = new DelegatingSecurityContextExecutorService(rawPool);
|
||||
setAuth("bob");
|
||||
run("B) DelegatingSecurityContextExecutorService.execute(...)", wrappedService);
|
||||
// submit() goes through the same wrapper -- context still travels
|
||||
CountDownLatch bLatch = new CountDownLatch(1);
|
||||
wrappedService.submit(() -> {
|
||||
Authentication a = SecurityContextHolder.getContext().getAuthentication();
|
||||
System.out.println("B2) DelegatingSecurityContextExecutorService.submit(Callable): " + describe(a));
|
||||
bLatch.countDown();
|
||||
return null;
|
||||
});
|
||||
bLatch.await(5, TimeUnit.SECONDS);
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
// --- Edge case: two tasks submitted with DIFFERENT contexts to the SAME reused pool
|
||||
// worker each keep their OWN context -- unlike Demo1's InheritableThreadLocal case,
|
||||
// where the second task on a reused worker saw the FIRST task's stale value. The
|
||||
// wrapper captures context per submission, not per thread.
|
||||
setAuth("carol-task1");
|
||||
CountDownLatch edgeLatch1 = new CountDownLatch(1);
|
||||
wrappedService.execute(() -> {
|
||||
System.out.println("EDGE) task 1 on possibly-reused worker: "
|
||||
+ describe(SecurityContextHolder.getContext().getAuthentication()));
|
||||
edgeLatch1.countDown();
|
||||
});
|
||||
edgeLatch1.await(5, TimeUnit.SECONDS);
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
setAuth("dave-task2");
|
||||
CountDownLatch edgeLatch2 = new CountDownLatch(1);
|
||||
wrappedService.execute(() -> {
|
||||
System.out.println("EDGE) task 2, same pool, different caller context: "
|
||||
+ describe(SecurityContextHolder.getContext().getAuthentication())
|
||||
+ " <-- correct, NOT stale, unlike plain InheritableThreadLocal on a reused worker");
|
||||
edgeLatch2.countDown();
|
||||
});
|
||||
edgeLatch2.await(5, TimeUnit.SECONDS);
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
// --- Scenario C: DelegatingSecurityContextExecutor + CompletableFuture.supplyAsync ---
|
||||
// Matches the post's CompletableFutureService. The common ForkJoinPool (the default
|
||||
// CompletableFuture executor) never propagates context; supplying a wrapped custom
|
||||
// executor fixes it.
|
||||
setAuth("erin");
|
||||
Executor delegatingExecutor = new DelegatingSecurityContextExecutor(rawPool);
|
||||
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
|
||||
Authentication a = SecurityContextHolder.getContext().getAuthentication();
|
||||
return "C) DelegatingSecurityContextExecutor + CompletableFuture.supplyAsync: " + describe(a);
|
||||
}, delegatingExecutor);
|
||||
System.out.println(cf.get(5, TimeUnit.SECONDS));
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
// Contrast: default CompletableFuture executor (common ForkJoinPool) -- unwrapped
|
||||
setAuth("frank");
|
||||
CompletableFuture<String> cfDefault = CompletableFuture.supplyAsync(() -> {
|
||||
Authentication a = SecurityContextHolder.getContext().getAuthentication();
|
||||
return "C2) default CompletableFuture executor (common ForkJoinPool), no wrapper: " + describe(a);
|
||||
});
|
||||
System.out.println(cfDefault.get(5, TimeUnit.SECONDS));
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
// --- Scenario D: DelegatingSecurityContextAsyncTaskExecutor wraps a Spring
|
||||
// ThreadPoolTaskExecutor -- the actual type AsyncConfigurer#getAsyncExecutor() returns,
|
||||
// one level above the raw java.util.concurrent classes above. This is the object
|
||||
// Spring's @Async infrastructure itself calls execute()/submit() on. ---
|
||||
ThreadPoolTaskExecutor springExecutor = new ThreadPoolTaskExecutor();
|
||||
springExecutor.setCorePoolSize(2);
|
||||
springExecutor.setMaxPoolSize(2);
|
||||
springExecutor.setThreadNamePrefix("Async-");
|
||||
springExecutor.initialize();
|
||||
DelegatingSecurityContextAsyncTaskExecutor asyncExecutor =
|
||||
new DelegatingSecurityContextAsyncTaskExecutor(springExecutor);
|
||||
setAuth("grace");
|
||||
CountDownLatch dLatch = new CountDownLatch(1);
|
||||
asyncExecutor.execute(() -> {
|
||||
Authentication a = SecurityContextHolder.getContext().getAuthentication();
|
||||
System.out.println("D) DelegatingSecurityContextAsyncTaskExecutor wrapping ThreadPoolTaskExecutor: " + describe(a));
|
||||
dLatch.countDown();
|
||||
});
|
||||
dLatch.await(5, TimeUnit.SECONDS);
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
rawPool.shutdown();
|
||||
springExecutor.shutdown();
|
||||
}
|
||||
|
||||
static void run(String label, Executor executor) throws InterruptedException {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
executor.execute(() -> {
|
||||
Authentication a = SecurityContextHolder.getContext().getAuthentication();
|
||||
System.out.println(label + ": " + describe(a));
|
||||
latch.countDown();
|
||||
});
|
||||
latch.await(5, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.ankurm.vt;
|
||||
|
||||
// Explained in docs/05-reactive-context.md -- run via scripts/run-all.sh, output captured in docs/output/
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* The post's {@code ReactiveController.getProfile()} example, without a running WebFlux
|
||||
* server -- {@code ReactiveSecurityContextHolder} reads from Project Reactor's subscriber
|
||||
* {@code Context}, which is not thread-bound, so it can be exercised with a plain
|
||||
* {@code Mono} chain and no {@code @RestController} at all.
|
||||
*
|
||||
* <p>Four scenarios. A and B are the "why {@code ThreadLocal} doesn't work here" argument
|
||||
* made concrete: the context is written with
|
||||
* {@code SecurityContextHolder.setContext(...)} on the subscribing thread, then the chain
|
||||
* is forced onto a <em>different</em> thread with {@code publishOn}, exactly as a real
|
||||
* WebFlux event loop would. C and D are the fix, {@code ReactiveSecurityContextHolder}
|
||||
* plus {@code contextWrite}, which the JEP note in the post's summary table calls out as
|
||||
* "Reactor Context" rather than "Scheduler wrapping".
|
||||
*/
|
||||
public class Demo5ReactiveContext {
|
||||
|
||||
static Authentication auth(String name) {
|
||||
return UsernamePasswordAuthenticationToken.authenticated(
|
||||
name, "n/a", AuthorityUtils.createAuthorityList("ROLE_USER"));
|
||||
}
|
||||
|
||||
/** The post's getProfile(), verbatim in spirit: reads ReactiveSecurityContextHolder,
|
||||
* maps to a greeting, defaults to "Anonymous" if nothing was ever written. */
|
||||
static Mono<String> getProfile() {
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.map(securityContext -> "Hello, " + securityContext.getAuthentication().getName())
|
||||
.defaultIfEmpty("Anonymous");
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
System.out.println("=== Demo 5: ReactiveSecurityContextHolder vs. ThreadLocal across a scheduler hop ===");
|
||||
|
||||
// --- Scenario A: plain ThreadLocal SecurityContextHolder, chain stays on caller thread ---
|
||||
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
|
||||
ctx.setAuthentication(auth("alice"));
|
||||
SecurityContextHolder.setContext(ctx);
|
||||
String a = Mono.fromSupplier(() -> {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
return auth == null ? "NO AUTHENTICATION (lost)" : "authenticated as " + auth.getName();
|
||||
})
|
||||
.block(Duration.ofSeconds(5));
|
||||
System.out.println("A) SecurityContextHolder (ThreadLocal), no scheduler hop: " + a);
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
// --- Scenario B: same ThreadLocal approach, but publishOn moves execution to a
|
||||
// different thread before the read happens -- exactly what a real WebFlux event
|
||||
// loop does between operators. The ThreadLocal set on the calling thread does not
|
||||
// follow. ---
|
||||
SecurityContextHolder.setContext(ctx);
|
||||
String b = Mono.fromSupplier(() -> "irrelevant")
|
||||
.publishOn(Schedulers.boundedElastic())
|
||||
.map(ignored -> {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
return auth == null ? "NO AUTHENTICATION (lost)" : "authenticated as " + auth.getName();
|
||||
})
|
||||
.block(Duration.ofSeconds(5));
|
||||
System.out.println("B) SecurityContextHolder (ThreadLocal), AFTER publishOn to a different thread: " + b
|
||||
+ " [proves ThreadLocal doesn't survive a scheduler hop]");
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
// --- Scenario C: ReactiveSecurityContextHolder + contextWrite, no scheduler hop ---
|
||||
String c = getProfile()
|
||||
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(auth("carol")))
|
||||
.block(Duration.ofSeconds(5));
|
||||
System.out.println("C) ReactiveSecurityContextHolder + contextWrite, no scheduler hop: " + c);
|
||||
|
||||
// --- Scenario D: same, but with a publishOn scheduler hop between the write and the
|
||||
// read -- the Reactor Context travels with the subscription, not the thread, so this
|
||||
// still resolves correctly where scenario B failed. ---
|
||||
String d = Mono.just("ignored")
|
||||
.publishOn(Schedulers.boundedElastic())
|
||||
.then(Mono.defer(Demo5ReactiveContext::getProfile))
|
||||
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(auth("dave")))
|
||||
.block(Duration.ofSeconds(5));
|
||||
System.out.println("D) ReactiveSecurityContextHolder + contextWrite, AFTER publishOn to a different thread: " + d
|
||||
+ " [Context travels with the stream, not the thread]");
|
||||
|
||||
// --- Edge case: no context was ever written -- defaultIfEmpty("Anonymous") fires,
|
||||
// not a null-pointer, because ReactiveSecurityContextHolder.getContext() completes
|
||||
// empty rather than emitting null when nothing was written upstream. ---
|
||||
String anon = getProfile().block(Duration.ofSeconds(5));
|
||||
System.out.println("E) getProfile() with no contextWrite() upstream at all: " + anon
|
||||
+ " [defaultIfEmpty fires; getContext() completes empty, it does not error]");
|
||||
|
||||
Schedulers.shutdownNow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.ankurm.vt;
|
||||
|
||||
// Explained in docs/06-scheduled-tasks.md -- run via scripts/run-all.sh, output captured in docs/output/
|
||||
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
|
||||
import org.springframework.security.authentication.AuthenticationTrustResolverImpl;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.scheduling.DelegatingSecurityContextTaskScheduler;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* There is no HTTP request behind a {@code @Scheduled} method, so unlike every other demo
|
||||
* in this repository this one is not really asking "does the context survive a thread
|
||||
* hand-off" -- it is asking "whose context, if anyone's, gets used at all".
|
||||
*
|
||||
* <p>Confirmed here by reading the actual bytecode of
|
||||
* {@code DelegatingSecurityContextTaskScheduler} before writing this demo (see the repo's
|
||||
* commit notes / project memory): the single-argument constructor stores a {@code null}
|
||||
* captured context, and {@code DelegatingSecurityContextRunnable} resolves a {@code null}
|
||||
* context lazily, inside {@code wrap()}, which runs synchronously on whatever thread calls
|
||||
* {@code schedule(...)}. That means the capture happens <b>per call to
|
||||
* {@code schedule()}</b>, not once when the wrapper is constructed -- scenario B below
|
||||
* proves that two {@code schedule()} calls on the same wrapper, made from a thread whose
|
||||
* context changed in between, capture two different contexts.
|
||||
*/
|
||||
public class Demo6ScheduledSystemIdentity {
|
||||
|
||||
static Authentication auth(String name) {
|
||||
return UsernamePasswordAuthenticationToken.authenticated(
|
||||
name, "n/a", AuthorityUtils.createAuthorityList("ROLE_USER"));
|
||||
}
|
||||
|
||||
static SecurityContext systemContext() {
|
||||
Authentication systemAuth = new UsernamePasswordAuthenticationToken(
|
||||
"SYSTEM", null, AuthorityUtils.createAuthorityList("ROLE_SYSTEM"));
|
||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(systemAuth);
|
||||
return context;
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
System.out.println("=== Demo 6: DelegatingSecurityContextTaskScheduler and the synthetic system identity ===");
|
||||
|
||||
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||
scheduler.setPoolSize(2);
|
||||
scheduler.setThreadNamePrefix("Scheduled-");
|
||||
scheduler.initialize();
|
||||
DelegatingSecurityContextTaskScheduler wrapped = new DelegatingSecurityContextTaskScheduler(scheduler);
|
||||
|
||||
// --- Scenario A: no SecurityContext on the calling thread at all when schedule() runs
|
||||
// -- the realistic case, since app startup usually has no authenticated user. ---
|
||||
SecurityContextHolder.clearContext();
|
||||
String a = runOnceAndCapture(wrapped, "A");
|
||||
System.out.println("A) schedule() called with NO context present on the caller thread: " + a
|
||||
+ " [this is the realistic startup case the post warns about]");
|
||||
|
||||
// --- Scenario B: prove capture is per schedule()-call, not per-wrapper-construction.
|
||||
// Two schedule() calls on the SAME wrapper instance, with the calling thread's
|
||||
// context changed in between, must NOT see each other's value. ---
|
||||
SecurityContext ctxX = SecurityContextHolder.createEmptyContext();
|
||||
ctxX.setAuthentication(auth("registration-thread-X"));
|
||||
SecurityContextHolder.setContext(ctxX);
|
||||
String b1 = runOnceAndCapture(wrapped, "B1");
|
||||
|
||||
SecurityContext ctxY = SecurityContextHolder.createEmptyContext();
|
||||
ctxY.setAuthentication(auth("registration-thread-Y"));
|
||||
SecurityContextHolder.setContext(ctxY);
|
||||
String b2 = runOnceAndCapture(wrapped, "B2");
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
System.out.println("B1) first schedule() call, caller context = registration-thread-X: " + b1);
|
||||
System.out.println("B2) second schedule() call on the SAME wrapper, caller context changed to registration-thread-Y: "
|
||||
+ b2 + " [independent per-call capture, not frozen at wrapper construction]");
|
||||
|
||||
// --- Scenario C: the recommended pattern -- ignore whatever DelegatingSecurityContextTaskScheduler
|
||||
// captured, and mint a narrow synthetic system identity inside the @Scheduled method body itself. ---
|
||||
CountDownLatch cLatch = new CountDownLatch(1);
|
||||
String[] cResult = new String[1];
|
||||
wrapped.schedule(() -> {
|
||||
SecurityContextHolder.setContext(systemContext());
|
||||
try {
|
||||
Authentication current = SecurityContextHolder.getContext().getAuthentication();
|
||||
cResult[0] = current.getName() + " with authorities " + current.getAuthorities();
|
||||
} finally {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
cLatch.countDown();
|
||||
}, Instant.now().plusMillis(50));
|
||||
cLatch.await(5, TimeUnit.SECONDS);
|
||||
System.out.println("C) task body sets its own systemContext(), ignoring anything the scheduler wrapper captured: "
|
||||
+ cResult[0]);
|
||||
|
||||
// --- Edge case: AuthenticationTrustResolver treats the synthetic SYSTEM principal as
|
||||
// a real (non-anonymous) authentication, same as any other UsernamePasswordAuthenticationToken
|
||||
// -- there is no built-in "system" concept in Spring Security, it's just a narrowly
|
||||
// scoped Authentication like any other, which is exactly why keeping its authority
|
||||
// list minimal matters. ---
|
||||
boolean anonymous = new AuthenticationTrustResolverImpl().isAnonymous(systemContext().getAuthentication());
|
||||
System.out.println("EDGE) AuthenticationTrustResolver.isAnonymous(systemContext()): " + anonymous
|
||||
+ " [false -- SYSTEM is a normal authenticated principal, not Spring Security's anonymous concept]");
|
||||
|
||||
scheduler.shutdown();
|
||||
}
|
||||
|
||||
private static String runOnceAndCapture(DelegatingSecurityContextTaskScheduler wrapped, String label) throws InterruptedException {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
String[] result = new String[1];
|
||||
wrapped.schedule(() -> {
|
||||
Authentication a = SecurityContextHolder.getContext().getAuthentication();
|
||||
result[0] = a == null ? "NO AUTHENTICATION (lost)" : "authenticated as " + a.getName();
|
||||
latch.countDown();
|
||||
}, Instant.now().plusMillis(50));
|
||||
latch.await(5, TimeUnit.SECONDS);
|
||||
return result[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.ankurm.vt;
|
||||
|
||||
// Explained in docs/07-servlet-filter-persistence.md -- run via scripts/run-all.sh, output captured in docs/output/
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.context.HttpRequestResponseHolder;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.security.web.context.SecurityContextHolderFilter;
|
||||
import org.springframework.security.web.context.SecurityContextPersistenceFilter;
|
||||
import org.springframework.security.web.context.SecurityContextRepository;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY;
|
||||
|
||||
/**
|
||||
* Direct verification of the post's claim in "Security Context Propagation in Servlet
|
||||
* Environment": {@code SecurityContextPersistenceFilter} loads the context AND saves it
|
||||
* back automatically at the end of the request; {@code SecurityContextHolderFilter} (the
|
||||
* Security-6+ default) only loads -- it never calls
|
||||
* {@code SecurityContextRepository.saveContext(...)} for you. Run against real filter
|
||||
* instances and a real {@code HttpSession}, not asserted from memory of the docs.
|
||||
*/
|
||||
public class Demo7ServletFilterPersistence {
|
||||
|
||||
static Authentication auth(String name) {
|
||||
return UsernamePasswordAuthenticationToken.authenticated(
|
||||
name, "n/a", AuthorityUtils.createAuthorityList("ROLE_USER"));
|
||||
}
|
||||
|
||||
/** Was a SecurityContext with this principal name actually written to the session? */
|
||||
static boolean sessionHasContextFor(HttpSession session, String name) {
|
||||
Object stored = session.getAttribute(SPRING_SECURITY_CONTEXT_KEY);
|
||||
if (!(stored instanceof SecurityContext sc)) return false;
|
||||
Authentication a = sc.getAuthentication();
|
||||
return a != null && name.equals(a.getName());
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
System.out.println("=== Demo 7: SecurityContextHolderFilter vs SecurityContextPersistenceFilter -- load vs. load+save ===");
|
||||
|
||||
// --- Scenario A: SecurityContextPersistenceFilter -- the deprecated, pre-6.0 default.
|
||||
// The controller sets an Authentication mid-chain; the filter is expected to persist
|
||||
// it to the session automatically once the chain returns. ---
|
||||
{
|
||||
SecurityContextRepository repoA = new HttpSessionSecurityContextRepository();
|
||||
SecurityContextPersistenceFilter filterA = new SecurityContextPersistenceFilter(repoA);
|
||||
MockHttpServletRequest reqA = new MockHttpServletRequest();
|
||||
MockHttpServletResponse respA = new MockHttpServletResponse();
|
||||
FilterChain chainA = (req, resp) -> {
|
||||
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
|
||||
ctx.setAuthentication(auth("alice"));
|
||||
SecurityContextHolder.setContext(ctx);
|
||||
};
|
||||
filterA.doFilter(reqA, respA, chainA);
|
||||
SecurityContextHolder.clearContext();
|
||||
boolean saved = sessionHasContextFor(reqA.getSession(false), "alice");
|
||||
System.out.println("A) SecurityContextPersistenceFilter, context set mid-chain, auto-saved to session after chain returns: "
|
||||
+ saved + " [true -- this filter saves for you]");
|
||||
}
|
||||
|
||||
// --- Scenario B: SecurityContextHolderFilter -- the Security 6+ default. Same steps.
|
||||
// No save call anywhere in its doFilter -- confirmed by reading its bytecode before
|
||||
// writing this demo (the class has no reference to SecurityContextRepository.saveContext
|
||||
// at all, only loadDeferredContext). ---
|
||||
{
|
||||
SecurityContextRepository repoB = new HttpSessionSecurityContextRepository();
|
||||
SecurityContextHolderFilter filterB = new SecurityContextHolderFilter(repoB);
|
||||
MockHttpServletRequest reqB = new MockHttpServletRequest();
|
||||
MockHttpServletResponse respB = new MockHttpServletResponse();
|
||||
FilterChain chainB = (req, resp) -> {
|
||||
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
|
||||
ctx.setAuthentication(auth("bob"));
|
||||
SecurityContextHolder.setContext(ctx);
|
||||
};
|
||||
filterB.doFilter(reqB, respB, chainB);
|
||||
SecurityContextHolder.clearContext();
|
||||
HttpSession sessionB = reqB.getSession(false);
|
||||
boolean saved = sessionB != null && sessionHasContextFor(sessionB, "bob");
|
||||
System.out.println("B) SecurityContextHolderFilter, context set mid-chain, auto-saved to session after chain returns: "
|
||||
+ saved + " [false -- requireExplicitSave's default; nothing persists unless you save it yourself]");
|
||||
}
|
||||
|
||||
// --- Scenario C: SecurityContextHolderFilter, but the application code inside the
|
||||
// chain calls SecurityContextRepository.saveContext(...) itself -- the fix the post
|
||||
// describes for custom pre-authentication filters that set the context directly. ---
|
||||
{
|
||||
SecurityContextRepository repoC = new HttpSessionSecurityContextRepository();
|
||||
SecurityContextHolderFilter filterC = new SecurityContextHolderFilter(repoC);
|
||||
MockHttpServletRequest reqC = new MockHttpServletRequest();
|
||||
MockHttpServletResponse respC = new MockHttpServletResponse();
|
||||
FilterChain chainC = (req, resp) -> {
|
||||
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
|
||||
ctx.setAuthentication(auth("carol"));
|
||||
SecurityContextHolder.setContext(ctx);
|
||||
repoC.saveContext(ctx, (jakarta.servlet.http.HttpServletRequest) req,
|
||||
(jakarta.servlet.http.HttpServletResponse) resp);
|
||||
};
|
||||
filterC.doFilter(reqC, respC, chainC);
|
||||
SecurityContextHolder.clearContext();
|
||||
boolean saved = sessionHasContextFor(reqC.getSession(false), "carol");
|
||||
System.out.println("C) SecurityContextHolderFilter + explicit repository.saveContext(...) inside the chain: "
|
||||
+ saved + " [true -- the workaround the post recommends actually works]");
|
||||
}
|
||||
|
||||
// --- Scenario D (load side): a session already carries a saved context from an
|
||||
// earlier "request" -- does SecurityContextHolderFilter load it back for the next one? ---
|
||||
{
|
||||
SecurityContextRepository repoD = new HttpSessionSecurityContextRepository();
|
||||
MockHttpServletRequest seedReq = new MockHttpServletRequest();
|
||||
MockHttpServletResponse seedResp = new MockHttpServletResponse();
|
||||
SecurityContext seeded = SecurityContextHolder.createEmptyContext();
|
||||
seeded.setAuthentication(auth("dave"));
|
||||
repoD.saveContext(seeded, seedReq, seedResp);
|
||||
HttpSession existingSession = seedReq.getSession();
|
||||
|
||||
SecurityContextHolderFilter filterD = new SecurityContextHolderFilter(repoD);
|
||||
MockHttpServletRequest reqD = new MockHttpServletRequest();
|
||||
reqD.setSession((org.springframework.mock.web.MockHttpSession) existingSession);
|
||||
MockHttpServletResponse respD = new MockHttpServletResponse();
|
||||
String[] seenInsideChain = new String[1];
|
||||
FilterChain chainD = (req, resp) -> {
|
||||
Authentication a = SecurityContextHolder.getContext().getAuthentication();
|
||||
seenInsideChain[0] = a == null ? "NO AUTHENTICATION (lost)" : "authenticated as " + a.getName();
|
||||
};
|
||||
filterD.doFilter(reqD, respD, chainD);
|
||||
SecurityContextHolder.clearContext();
|
||||
System.out.println("D) SecurityContextHolderFilter, context already saved in an existing session, next request: "
|
||||
+ seenInsideChain[0] + " [it does load -- \"only loads, never saves\" describes the SAVE side, not the LOAD side]");
|
||||
}
|
||||
|
||||
// --- Edge case: NEITHER filter has any effect if the request never populates a
|
||||
// session at all AND nothing was ever saved -- SecurityContextHolder simply reflects
|
||||
// an empty context, same as scenario A/B's baseline. Worth stating explicitly because
|
||||
// it's easy to assume "no context" means the filter is broken rather than that nothing
|
||||
// was ever authenticated on this request. ---
|
||||
{
|
||||
SecurityContextRepository repoE = new HttpSessionSecurityContextRepository();
|
||||
SecurityContextHolderFilter filterE = new SecurityContextHolderFilter(repoE);
|
||||
MockHttpServletRequest reqE = new MockHttpServletRequest();
|
||||
MockHttpServletResponse respE = new MockHttpServletResponse();
|
||||
String[] seen = new String[1];
|
||||
FilterChain chainE = (req, resp) -> {
|
||||
Authentication a = SecurityContextHolder.getContext().getAuthentication();
|
||||
seen[0] = a == null ? "NO AUTHENTICATION (empty context, not an error)" : "authenticated as " + a.getName();
|
||||
};
|
||||
filterE.doFilter(reqE, respE, chainE);
|
||||
SecurityContextHolder.clearContext();
|
||||
System.out.println("EDGE) brand-new request, no prior session, nothing set: " + seen[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package com.ankurm.vt;
|
||||
|
||||
// Explained in docs/08-testing-contract.md -- run via `mvn test` (uses --enable-preview
|
||||
// via the surefire argLine in pom.xml). Pins the CONTRACT each demo asserts with a println,
|
||||
// as real JUnit assertions: which scenario keeps the SecurityContext, which loses it, and
|
||||
// which status code / boolean the guide's claims translate to. This is the repo's answer to
|
||||
// the post's "Testing Security Context Propagation" section -- including a real
|
||||
// TestSecurityContextHolder-based test, scenario 12 below, matching that section's
|
||||
// testAsyncWithManualContext example almost line for line.
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import org.assertj.core.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.mock.web.MockHttpSession;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.concurrent.DelegatingSecurityContextExecutor;
|
||||
import org.springframework.security.concurrent.DelegatingSecurityContextExecutorService;
|
||||
import org.springframework.security.scheduling.DelegatingSecurityContextTaskScheduler;
|
||||
import org.springframework.security.task.DelegatingSecurityContextAsyncTaskExecutor;
|
||||
import org.springframework.security.test.context.TestSecurityContextHolder;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.security.web.context.SecurityContextHolderFilter;
|
||||
import org.springframework.security.web.context.SecurityContextPersistenceFilter;
|
||||
import org.springframework.security.web.context.SecurityContextRepository;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY;
|
||||
|
||||
class SecurityContextPropagationContractTest {
|
||||
|
||||
static Authentication auth(String name) {
|
||||
return UsernamePasswordAuthenticationToken.authenticated(
|
||||
name, "n/a", AuthorityUtils.createAuthorityList("ROLE_USER"));
|
||||
}
|
||||
|
||||
static void setAuth(String name) {
|
||||
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
|
||||
ctx.setAuthentication(auth(name));
|
||||
SecurityContextHolder.setContext(ctx);
|
||||
}
|
||||
|
||||
// --- Demo 4: Executor / ExecutorService / AsyncTaskExecutor wrapping -------------------
|
||||
|
||||
@Test
|
||||
void rawThreadPoolExecutorLosesContext() throws Exception {
|
||||
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
|
||||
setAuth("alice");
|
||||
Authentication[] seen = new Authentication[1];
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
pool.execute(() -> { seen[0] = SecurityContextHolder.getContext().getAuthentication(); latch.countDown(); });
|
||||
latch.await(5, TimeUnit.SECONDS);
|
||||
SecurityContextHolder.clearContext();
|
||||
pool.shutdown();
|
||||
assertThat(seen[0]).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void delegatingSecurityContextExecutorServicePropagatesAndCapturesIndependentlyPerTask() throws Exception {
|
||||
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
|
||||
var wrapped = new DelegatingSecurityContextExecutorService(pool);
|
||||
|
||||
setAuth("task-one");
|
||||
Authentication[] seenOne = new Authentication[1];
|
||||
CountDownLatch l1 = new CountDownLatch(1);
|
||||
wrapped.execute(() -> { seenOne[0] = SecurityContextHolder.getContext().getAuthentication(); l1.countDown(); });
|
||||
l1.await(5, TimeUnit.SECONDS);
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
// Same pool worker is very likely reused here (pool size 1) -- if this wrapper
|
||||
// behaved like plain InheritableThreadLocal, task two would see task one's stale
|
||||
// value. It must not.
|
||||
setAuth("task-two");
|
||||
Authentication[] seenTwo = new Authentication[1];
|
||||
CountDownLatch l2 = new CountDownLatch(1);
|
||||
wrapped.execute(() -> { seenTwo[0] = SecurityContextHolder.getContext().getAuthentication(); l2.countDown(); });
|
||||
l2.await(5, TimeUnit.SECONDS);
|
||||
SecurityContextHolder.clearContext();
|
||||
pool.shutdown();
|
||||
|
||||
assertThat(seenOne[0].getName()).isEqualTo("task-one");
|
||||
assertThat(seenTwo[0].getName()).isEqualTo("task-two");
|
||||
}
|
||||
|
||||
@Test
|
||||
void completableFutureDefaultExecutorLosesContext_delegatingExecutorPropagates() throws Exception {
|
||||
setAuth("erin");
|
||||
CompletableFuture<Authentication> lost = CompletableFuture.supplyAsync(
|
||||
() -> SecurityContextHolder.getContext().getAuthentication());
|
||||
assertThat(lost.get(5, TimeUnit.SECONDS)).isNull();
|
||||
|
||||
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
|
||||
var delegating = new DelegatingSecurityContextExecutor(pool);
|
||||
CompletableFuture<Authentication> kept = CompletableFuture.supplyAsync(
|
||||
() -> SecurityContextHolder.getContext().getAuthentication(), delegating);
|
||||
assertThat(kept.get(5, TimeUnit.SECONDS).getName()).isEqualTo("erin");
|
||||
SecurityContextHolder.clearContext();
|
||||
pool.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void delegatingSecurityContextAsyncTaskExecutorPropagates() throws Exception {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(1);
|
||||
executor.setMaxPoolSize(1);
|
||||
executor.initialize();
|
||||
var wrapped = new DelegatingSecurityContextAsyncTaskExecutor(executor);
|
||||
|
||||
setAuth("grace");
|
||||
Authentication[] seen = new Authentication[1];
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
wrapped.execute(() -> { seen[0] = SecurityContextHolder.getContext().getAuthentication(); latch.countDown(); });
|
||||
latch.await(5, TimeUnit.SECONDS);
|
||||
SecurityContextHolder.clearContext();
|
||||
executor.shutdown();
|
||||
|
||||
assertThat(seen[0].getName()).isEqualTo("grace");
|
||||
}
|
||||
|
||||
// --- Demo 5: Reactive context -----------------------------------------------------------
|
||||
|
||||
static Mono<String> getProfile() {
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.map(sc -> "Hello, " + sc.getAuthentication().getName())
|
||||
.defaultIfEmpty("Anonymous");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reactiveContextSurvivesSchedulerHop_threadLocalDoesNot() {
|
||||
setAuth("dave-threadlocal");
|
||||
StepVerifier.create(
|
||||
Mono.just("x")
|
||||
.publishOn(Schedulers.boundedElastic())
|
||||
// map() cannot emit null, so report presence/absence as a String
|
||||
// rather than the (possibly null) Authentication itself.
|
||||
.map(ignored -> SecurityContextHolder.getContext().getAuthentication() == null
|
||||
? "NO AUTHENTICATION (lost)" : "unexpectedly present"))
|
||||
.expectNext("NO AUTHENTICATION (lost)")
|
||||
.verifyComplete();
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
StepVerifier.create(
|
||||
Mono.just("x")
|
||||
.publishOn(Schedulers.boundedElastic())
|
||||
.then(Mono.defer(SecurityContextPropagationContractTest::getProfile))
|
||||
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(auth("dave-reactive"))))
|
||||
.expectNext("Hello, dave-reactive")
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reactiveGetProfileDefaultsToAnonymousWithNoUpstreamContext() {
|
||||
StepVerifier.create(getProfile()).expectNext("Anonymous").verifyComplete();
|
||||
}
|
||||
|
||||
// --- Demo 6: Scheduled tasks --------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void delegatingSecurityContextTaskSchedulerCapturesPerScheduleCallNotAtConstruction() throws Exception {
|
||||
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||
scheduler.setPoolSize(1);
|
||||
scheduler.initialize();
|
||||
var wrapped = new DelegatingSecurityContextTaskScheduler(scheduler);
|
||||
|
||||
setAuth("registration-X");
|
||||
Authentication[] seenX = new Authentication[1];
|
||||
CountDownLatch lx = new CountDownLatch(1);
|
||||
wrapped.schedule(() -> { seenX[0] = SecurityContextHolder.getContext().getAuthentication(); lx.countDown(); },
|
||||
Instant.now().plusMillis(20));
|
||||
lx.await(5, TimeUnit.SECONDS);
|
||||
|
||||
setAuth("registration-Y");
|
||||
Authentication[] seenY = new Authentication[1];
|
||||
CountDownLatch ly = new CountDownLatch(1);
|
||||
wrapped.schedule(() -> { seenY[0] = SecurityContextHolder.getContext().getAuthentication(); ly.countDown(); },
|
||||
Instant.now().plusMillis(20));
|
||||
ly.await(5, TimeUnit.SECONDS);
|
||||
SecurityContextHolder.clearContext();
|
||||
scheduler.shutdown();
|
||||
|
||||
assertThat(seenX[0].getName()).isEqualTo("registration-X");
|
||||
assertThat(seenY[0].getName()).isEqualTo("registration-Y");
|
||||
}
|
||||
|
||||
// --- Demo 7: Servlet filter load vs. save ------------------------------------------------
|
||||
|
||||
@Test
|
||||
void securityContextPersistenceFilterAutoSaves_holderFilterDoesNot() throws Exception {
|
||||
SecurityContextRepository repoA = new HttpSessionSecurityContextRepository();
|
||||
var persistenceFilter = new SecurityContextPersistenceFilter(repoA);
|
||||
MockHttpServletRequest reqA = new MockHttpServletRequest();
|
||||
MockHttpServletResponse respA = new MockHttpServletResponse();
|
||||
FilterChain chainA = (req, resp) -> {
|
||||
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
|
||||
ctx.setAuthentication(auth("alice"));
|
||||
SecurityContextHolder.setContext(ctx);
|
||||
};
|
||||
persistenceFilter.doFilter(reqA, respA, chainA);
|
||||
SecurityContextHolder.clearContext();
|
||||
assertThat(sessionContextName(reqA.getSession(false))).isEqualTo("alice");
|
||||
|
||||
SecurityContextRepository repoB = new HttpSessionSecurityContextRepository();
|
||||
var holderFilter = new SecurityContextHolderFilter(repoB);
|
||||
MockHttpServletRequest reqB = new MockHttpServletRequest();
|
||||
MockHttpServletResponse respB = new MockHttpServletResponse();
|
||||
FilterChain chainB = (req, resp) -> {
|
||||
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
|
||||
ctx.setAuthentication(auth("bob"));
|
||||
SecurityContextHolder.setContext(ctx);
|
||||
};
|
||||
holderFilter.doFilter(reqB, respB, chainB);
|
||||
SecurityContextHolder.clearContext();
|
||||
assertThat(sessionContextName(reqB.getSession(false))).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void securityContextHolderFilterLoadsAnExistingSession() throws Exception {
|
||||
SecurityContextRepository repo = new HttpSessionSecurityContextRepository();
|
||||
MockHttpServletRequest seedReq = new MockHttpServletRequest();
|
||||
MockHttpServletResponse seedResp = new MockHttpServletResponse();
|
||||
SecurityContext seeded = SecurityContextHolder.createEmptyContext();
|
||||
seeded.setAuthentication(auth("existing-user"));
|
||||
repo.saveContext(seeded, seedReq, seedResp);
|
||||
|
||||
var holderFilter = new SecurityContextHolderFilter(repo);
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.setSession((MockHttpSession) seedReq.getSession());
|
||||
MockHttpServletResponse resp = new MockHttpServletResponse();
|
||||
Authentication[] seen = new Authentication[1];
|
||||
holderFilter.doFilter(req, resp, (r, s) -> seen[0] = SecurityContextHolder.getContext().getAuthentication());
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
assertThat(seen[0].getName()).isEqualTo("existing-user");
|
||||
}
|
||||
|
||||
private static String sessionContextName(jakarta.servlet.http.HttpSession session) {
|
||||
if (session == null) return null;
|
||||
Object stored = session.getAttribute(SPRING_SECURITY_CONTEXT_KEY);
|
||||
if (!(stored instanceof SecurityContext sc) || sc.getAuthentication() == null) return null;
|
||||
return sc.getAuthentication().getName();
|
||||
}
|
||||
|
||||
// --- The post's own "Testing Security Context Propagation" section, reproduced ----------
|
||||
|
||||
/**
|
||||
* Mirrors {@code AsyncServiceTest.testAsyncWithManualContext} from the post almost line
|
||||
* for line: set up a context manually via {@code TestSecurityContextHolder} (the class
|
||||
* the post names as the alternative to {@code @WithMockUser}), run an async operation
|
||||
* through a {@code Delegating*} wrapper, and assert the result carries the test
|
||||
* principal's name. Confirms {@code TestSecurityContextHolder} and
|
||||
* {@code SecurityContextHolder} really are reading and writing the same underlying
|
||||
* holder -- there's exactly one strategy per JVM (per thread, for the default
|
||||
* {@code MODE_THREADLOCAL} strategy), test or production code.
|
||||
*/
|
||||
@Test
|
||||
void testSecurityContextHolderIsTheSameHolderTestSecurityContextHolderWrites() throws Exception {
|
||||
TestSecurityContextHolder.setAuthentication(auth("testuser"));
|
||||
try {
|
||||
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
|
||||
var wrapped = new DelegatingSecurityContextExecutorService(pool);
|
||||
CompletableFuture<String> future = new CompletableFuture<>();
|
||||
wrapped.execute(() -> {
|
||||
Authentication a = SecurityContextHolder.getContext().getAuthentication();
|
||||
future.complete("Processed by: " + (a == null ? "nobody" : a.getName()));
|
||||
});
|
||||
String result = future.get(5, TimeUnit.SECONDS);
|
||||
pool.shutdown();
|
||||
Assertions.assertThat(result).contains("testuser");
|
||||
} finally {
|
||||
TestSecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user