Add the filter-chain module
Companion project for "The Spring Security Filter Chain Explained". A real Spring Boot 4.1.1 servlet application whose scenarios are Spring profiles, plus a diagnostic controller that prints the live FilterChainProxy, the reflected FilterOrderRegistration table, and the servlet container's own registrations. Twelve captured transcripts under docs/output/, nine cross-linked doc chapters, 21 assertions. Also fixes a broken relative link in method-security/docs/01: the cross-module reference to context-propagation/README.md needed two levels up, not one.
This commit is contained in:
118
filter-chain/README.md
Normal file
118
filter-chain/README.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# `filter-chain` — the Spring Security filter chain, printed from a running application
|
||||
|
||||
Companion project for
|
||||
[**The Spring Security Filter Chain Explained**](https://ankurm.com/spring-security-filter-chain-explained/)
|
||||
on ankurm.com.
|
||||
|
||||
Every claim the article makes about filter ordering is produced here by a real Boot application
|
||||
and captured under [`docs/output/`](docs/output/). The chain tables are read back out of the live
|
||||
`FilterChainProxy` bean; the order numbers are reflected out of `spring-security-config`'s own
|
||||
`FilterOrderRegistration`. Nothing is transcribed from documentation.
|
||||
|
||||
## Versions
|
||||
|
||||
| | Version | Notes |
|
||||
|---|---|---|
|
||||
| JDK | 25 (Temurin 25.0.4.1+1) | current LTS |
|
||||
| Spring Boot | 4.1.1 | inherited as parent, so every version below is Boot-managed |
|
||||
| Spring Framework | 7.0.9 | |
|
||||
| Spring Security | 7.1.1 | latest GA; 7.2.0-M1 is a milestone, not a release |
|
||||
| Tomcat | 11.0.x | Boot-managed |
|
||||
| JUnit Jupiter | 6.x / AssertJ 3.x | Boot-managed |
|
||||
|
||||
Versions were taken from `repo1.maven.org/.../maven-metadata.xml`, not from release
|
||||
announcements.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
./scripts/run.sh baseline # start with the reference configuration
|
||||
curl -s localhost:8080/diag/chains
|
||||
curl -s localhost:8080/diag/order
|
||||
|
||||
TRACE=1 ./scripts/run.sh baseline
|
||||
curl -s -u alice:password localhost:8080/whoami # then read /tmp/filter-chain-app.log
|
||||
|
||||
./scripts/run-all.sh # every scenario, regenerating docs/output/
|
||||
mvn test # just the 21 assertions
|
||||
./scripts/stop.sh
|
||||
```
|
||||
|
||||
Users are `alice` / `password` (`ROLE_USER`) and `root` / `password` (`ROLE_ADMIN`).
|
||||
|
||||
## Profiles
|
||||
|
||||
Each scenario in the article is a profile on this one application.
|
||||
|
||||
| Profile | Configuration | Shows |
|
||||
|---|---|---|
|
||||
| `baseline` *(default)* | [`BaselineSecurityConfig`](src/main/java/com/ankurm/chain/config/BaselineSecurityConfig.java) | The reference 16-filter chain |
|
||||
| `custom` | [`CustomFiltersSecurityConfig`](src/main/java/com/ankurm/chain/config/CustomFiltersSecurityConfig.java) | Four custom filters at four anchors; the `ExceptionTranslationFilter` boundary |
|
||||
| `misordered` | [`MisorderedSecurityConfig`](src/main/java/com/ankurm/chain/config/MisorderedSecurityConfig.java) | An authentication filter after `AuthorizationFilter` — 401 with a valid credential |
|
||||
| `tie` | [`TieSecurityConfig`](src/main/java/com/ankurm/chain/config/TieSecurityConfig.java) | Two filters on one anchor. `-DTIE_REVERSED=true` flips them |
|
||||
| `doublereg` | [`DoubleRegistrationConfig`](src/main/java/com/ankurm/chain/config/DoubleRegistrationConfig.java) | A filter bean registered twice. Add `,fixed` for the cure |
|
||||
| `multichain` | [`MultiChainSecurityConfig`](src/main/java/com/ankurm/chain/config/MultiChainSecurityConfig.java) | Three chains of three different lengths |
|
||||
| `ignoring` | [`IgnoringSecurityConfig`](src/main/java/com/ankurm/chain/config/IgnoringSecurityConfig.java) | `WebSecurity.ignoring()` — a chain with zero filters |
|
||||
|
||||
Two JVM flags change behaviour rather than configuration:
|
||||
|
||||
| Flag | Effect |
|
||||
|---|---|
|
||||
| `-DTIE_REVERSED=true` | Swaps the two `addFilterBefore` calls in the `tie` profile |
|
||||
| `-DUNIQUE_ONCE_KEY=true` | Gives each `TenantFilter` its own `OncePerRequestFilter` key, so the second one stops silently skipping itself |
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /diag/chains` | Every `SecurityFilterChain` in `FilterChainProxy`, with each chain's filters in order |
|
||||
| `GET /diag/order` | The whole `FilterOrderRegistration` table, marking slots whose class is absent |
|
||||
| `GET /diag/servlet-filters` | What the **container** has registered — where double registration shows up |
|
||||
| `GET /whoami` | Who the chain decided you are by the time a controller runs |
|
||||
| `GET /public/hello` | `permitAll` |
|
||||
| `GET /static/asset.txt` | Under `ignoring()` in the `ignoring` profile |
|
||||
| `GET /api/data` | Served by the API chain under `multichain` |
|
||||
| `POST /hello` | For provoking a CSRF rejection |
|
||||
| `GET /tenant/doc`, `GET /tenant/translated` | Guarded by `TenantFilter`s either side of `ExceptionTranslationFilter` |
|
||||
| `GET /markers` | Executed order of the tied marker filters |
|
||||
|
||||
The `/diag/*` endpoints are the interesting part of this project and also the reason you would
|
||||
never ship it. Delete `DiagnosticsController` before deploying anything resembling this.
|
||||
|
||||
## Documentation
|
||||
|
||||
Nine chapters under [`docs/`](docs/README.md), starting with
|
||||
[01 · The two proxies](docs/01-the-two-proxies.md).
|
||||
|
||||
## What this module found
|
||||
|
||||
Things that are true of Spring Security 7.1.1 and are not in the reference documentation:
|
||||
|
||||
- The default chain is **sixteen** filters, not the fifteen the docs list —
|
||||
`DefaultResourcesFilter` (2400) is missing from that sample, and the startup log format has
|
||||
changed too.
|
||||
- Orders **300** and **4100** name `ChannelProcessingFilter` and `FilterSecurityInterceptor`,
|
||||
both **removed in 7.0**. The slots were kept so no other number moved.
|
||||
- `addFilterBefore` resolves its anchor against a **static table**, not against the chain being
|
||||
built — so you can anchor to a filter you have disabled, and it still works.
|
||||
- Two filters on the same anchor get the **same order number**; the tie is broken by
|
||||
`List.sort` being stable, which is a `java.util.List` guarantee and not a Spring Security one.
|
||||
- Two instances of the same `OncePerRequestFilter` subclass in one chain **share their
|
||||
already-filtered key**, and the second one silently never runs.
|
||||
- The documented placement for an authorization filter (after `AnonymousAuthenticationFilter`,
|
||||
3701) is **below** `ExceptionTranslationFilter` (4000), so the `AccessDeniedException` the
|
||||
documented example throws produces a 500, not a 403.
|
||||
- On the `/error` re-dispatch after a rejection, `FilterChainProxy` runs the whole chain again,
|
||||
but the six `OncePerRequestFilter`-based filters skip themselves — so the error page is
|
||||
**authorized but not authenticated**.
|
||||
|
||||
## Related modules
|
||||
|
||||
| Module | Article |
|
||||
|---|---|
|
||||
| [`context-propagation/`](../context-propagation/README.md) | [Spring Security Context Propagation](https://ankurm.com/spring-security-context-propagation-complete-guide/) |
|
||||
| [`method-security/`](../method-security/README.md) | [Method Security in Spring Security 7](https://ankurm.com/spring-security-7-method-security-proxy-traps/) |
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [`../LICENSE`](../LICENSE).
|
||||
93
filter-chain/docs/01-the-two-proxies.md
Normal file
93
filter-chain/docs/01-the-two-proxies.md
Normal file
@@ -0,0 +1,93 @@
|
||||
[← docs index](README.md) · **01 · The two proxies** · [02 · The default chain →](02-the-default-chain.md)
|
||||
|
||||
# 01 · The two proxies, and why there are two
|
||||
|
||||
Spring Security is a single servlet filter. Everything else it does in a web application happens
|
||||
inside that one filter.
|
||||
|
||||
The container knows about exactly one entry:
|
||||
|
||||
```
|
||||
countingFilter com.ankurm.chain.filter.CountingFilter urls=[/*]
|
||||
springSecurityFilterChain org.springframework.boot.web.servlet.DelegatingFilterProxyRegistrationBean$1 urls=[/*]
|
||||
```
|
||||
|
||||
That comes from [`docs/output/demo12-servlet-filters.txt`](output/demo12-servlet-filters.txt),
|
||||
printed live from `ServletContext.getFilterRegistrations()`. Note what is *not* in it: none of
|
||||
the sixteen filters that actually make the security decision. The container cannot see them.
|
||||
|
||||
## DelegatingFilterProxy
|
||||
|
||||
The container instantiates filters itself, long before Spring's `ApplicationContext` exists, and
|
||||
it has no idea what a bean is. `DelegatingFilterProxy` bridges that: it is a real
|
||||
`jakarta.servlet.Filter` registered with the container, and on the first request it looks up a
|
||||
Spring bean by name and forwards to it.
|
||||
|
||||
In Boot the registration is not literally a `DelegatingFilterProxy` — it is an anonymous
|
||||
subclass created by `DelegatingFilterProxyRegistrationBean`, which is why the class name above
|
||||
ends in `$1`. Functionally it is the same thing.
|
||||
|
||||
Two consequences worth internalising:
|
||||
|
||||
- The security chain has a **position in the container's filter list**, and things registered
|
||||
ahead of it run before any of Spring Security exists for that request. Boot registers it at
|
||||
order **-100** (`SecurityFilterProperties.DEFAULT_FILTER_ORDER`, settable with
|
||||
`spring.security.filter.order`), behind `characterEncodingFilter` and `formContentFilter`
|
||||
(`OrderedFormContentFilter.DEFAULT_ORDER` is -9900).
|
||||
- It is registered for **every** `DispatcherType`. `SecurityFilterProperties.dispatcherTypes`
|
||||
defaults to `EnumSet.allOf(DispatcherType.class)`, which is exactly why the whole chain runs a
|
||||
second time on the internal `/error` dispatch after a rejection — see
|
||||
[06 · Reading the TRACE output](06-reading-the-trace.md#misleading-thing-3--the-rejected-request-runs-the-chain-twice).
|
||||
Narrow it with `spring.security.filter.dispatcher-types=request` if you want the security
|
||||
chain out of error and forward dispatches.
|
||||
- A `Filter` bean you declare is registered with the *container* automatically. That is the
|
||||
mechanism behind the double-registration trap in
|
||||
[05 · Failure modes](05-failure-modes.md#a-custom-filter-that-runs-twice).
|
||||
|
||||
> **Boot 4 moved these constants.** `SecurityProperties.DEFAULT_FILTER_ORDER` and
|
||||
> `SecurityProperties.BASIC_AUTH_ORDER` — the latter a common `@Order(...)` argument — are now
|
||||
> on `org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties`.
|
||||
> The values are unchanged (-100 and `Integer.MAX_VALUE - 5`); `SecurityProperties` in 4.1.1
|
||||
> retains only `getUser()`. `IGNORED_ORDER` does not appear anywhere in the Boot 4.1.1 jars.
|
||||
|
||||
## FilterChainProxy
|
||||
|
||||
The bean `DelegatingFilterProxy` forwards to is `FilterChainProxy`. It is not a chain; it holds
|
||||
a list of chains. On each request it walks its `SecurityFilterChain` list in order, asks each one
|
||||
whether it matches, and **invokes the first one that says yes**. The rest are never consulted —
|
||||
there is no "and then the next chain too".
|
||||
|
||||
It also does three things nothing else does:
|
||||
|
||||
1. Applies the `HttpFirewall` before any chain runs. A request rejected here never reaches your
|
||||
filters, which is why a `RequestRejectedException` looks like it came from nowhere.
|
||||
2. Clears `SecurityContextHolder` in a `finally` block, so a thread returned to the pool does
|
||||
not carry someone else's identity. (The
|
||||
[context-propagation module](../../context-propagation/README.md) is about what happens when you
|
||||
leave that thread.)
|
||||
3. Wraps each filter in a decorator. With Micrometer on the classpath — Boot's actuator starter
|
||||
pulls it in — that decorator is `ObservationFilterChainDecorator`, and you will see two extra
|
||||
frames per filter in every stack trace:
|
||||
|
||||
```
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(...)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(...)
|
||||
```
|
||||
|
||||
That is from [`demo4-trace-csrf-403.txt`](output/demo4-trace-csrf-403.txt), a real stack
|
||||
trace from this application. Without Micrometer you get the plain `VirtualFilterChain` and
|
||||
half the frames.
|
||||
|
||||
## SecurityFilterChain
|
||||
|
||||
A `RequestMatcher` plus an ordered `List<Filter>`. That is the whole interface. `HttpSecurity`
|
||||
is a builder that produces one; `@Bean SecurityFilterChain` is how you register it.
|
||||
|
||||
The list is ordered by `HttpSecurity.performBuild()`, which sorts with `OrderComparator` and
|
||||
then throws the ordering wrappers away. By the time `FilterChainProxy` sees the chain, the order
|
||||
is baked in and no longer inspectable — which is why this module reads the numbers back out of
|
||||
the [order table](03-the-order-table.md) instead.
|
||||
|
||||
---
|
||||
|
||||
[← docs index](README.md) · **01 · The two proxies** · [02 · The default chain →](02-the-default-chain.md)
|
||||
69
filter-chain/docs/02-the-default-chain.md
Normal file
69
filter-chain/docs/02-the-default-chain.md
Normal file
@@ -0,0 +1,69 @@
|
||||
[← 01 · The two proxies](01-the-two-proxies.md) · **02 · The default chain** · [03 · The order table →](03-the-order-table.md)
|
||||
|
||||
# 02 · The default chain, filter by filter
|
||||
|
||||
The configuration in [`BaselineSecurityConfig`](../src/main/java/com/ankurm/chain/config/BaselineSecurityConfig.java)
|
||||
turns on four things — CSRF, HTTP Basic, form login, request authorization — and gets
|
||||
**sixteen filters**. Captured live in
|
||||
[`docs/output/demo2-default-chain.txt`](output/demo2-default-chain.txt) and pinned by
|
||||
`FilterChainContractTest.Baseline.defaultChainIsSixteenFilters`.
|
||||
|
||||
| # | Order | Filter | What it does | Skip it and… |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 100 | `DisableEncodeUrlFilter` | Stops the container appending `;jsessionid=` to URLs | Session ids leak into logs, referrers and bookmarks |
|
||||
| 2 | 600 | `WebAsyncManagerIntegrationFilter` | Bridges the `SecurityContext` into `Callable` async MVC | `@Async`-style MVC returns lose the principal |
|
||||
| 3 | 700 | `SecurityContextHolderFilter` | Installs a **deferred** context supplier; clears the holder afterwards | Nothing loads the stored context |
|
||||
| 4 | 900 | `HeaderWriterFilter` | `X-Content-Type-Options`, `X-XSS-Protection`, cache headers, HSTS on TLS | No security headers |
|
||||
| 5 | 1100 | `CsrfFilter` | Validates the token on state-changing methods | CSRF |
|
||||
| 6 | 1200 | `LogoutFilter` | Matches `POST /logout`, clears the context and session | `/logout` 404s |
|
||||
| 7 | 2100 | `UsernamePasswordAuthenticationFilter` | Matches `POST /login`, authenticates form credentials | Form login does nothing |
|
||||
| 8 | 2400 | `DefaultResourcesFilter` | Serves the CSS the generated login page links to | Unstyled default login page |
|
||||
| 9 | 2500 | `DefaultLoginPageGeneratingFilter` | Renders `GET /login` when you have not supplied one | No login page |
|
||||
| 10 | 2600 | `DefaultLogoutPageGeneratingFilter` | Renders `GET /logout` confirmation | No logout page |
|
||||
| 11 | 3100 | `BasicAuthenticationFilter` | Reads the `Authorization: Basic` header | HTTP Basic does nothing |
|
||||
| 12 | 3300 | `RequestCacheAwareFilter` | Replays the request that triggered a login redirect | Users land on `/` after login instead of where they were |
|
||||
| 13 | 3400 | `SecurityContextHolderAwareRequestFilter` | Wraps the request so `getUserPrincipal()`/`isUserInRole()` work | Servlet-API security methods return null |
|
||||
| 14 | 3700 | `AnonymousAuthenticationFilter` | Substitutes an `AnonymousAuthenticationToken` when nothing authenticated | `getAuthentication()` returns `null` and `.anonymous()` rules break |
|
||||
| 15 | 4000 | `ExceptionTranslationFilter` | Turns `AuthenticationException`/`AccessDeniedException` into 401/403 or a redirect | Denials surface as **500** |
|
||||
| 16 | 4200 | `AuthorizationFilter` | Runs `authorizeHttpRequests` and denies | Nothing is authorized |
|
||||
|
||||
## Three things about that table
|
||||
|
||||
**`DefaultResourcesFilter` is the sixteenth.** The list in the reference documentation has
|
||||
fifteen and omits it; that sample also uses the older `Will secure any request with [ … ]`
|
||||
wording, which 7.1.1 no longer prints. The current wording is
|
||||
`Will secure any request with filters: …` — verified against the constant pool of
|
||||
`DefaultSecurityFilterChain` in 6.5.1, 7.0.7 and 7.1.1, all three of which use the newer form.
|
||||
If you grep your logs for the bracketed version you will find nothing.
|
||||
|
||||
**`SessionManagementFilter` is not there.** Nor is `SecurityContextPersistenceFilter`. Plenty of
|
||||
articles still list both. `SessionManagementFilter` appears only when you configure
|
||||
`sessionManagement(…)` explicitly — see the API chain in
|
||||
[`demo10-multichain.txt`](output/demo10-multichain.txt), where asking for `STATELESS` *adds* a
|
||||
filter at 3900. `SecurityContextPersistenceFilter` was superseded by `SecurityContextHolderFilter`
|
||||
in 6.0 and is deprecated.
|
||||
|
||||
**Filter 3 does not read the session.** `SecurityContextHolderFilter` installs a
|
||||
`SupplierDeferredSecurityContext`; the session read happens on first access. In
|
||||
[`demo3-trace-authenticated.txt`](output/demo3-trace-authenticated.txt) the line
|
||||
`HttpSessionSecurityContextRepository : No HttpSession currently exists` appears under filter
|
||||
**11**, not filter 3, because `BasicAuthenticationFilter` was the first thing to ask. That is a
|
||||
feature — a request that never touches the context never touches the session — and it is also
|
||||
why reading the TRACE log as a strict timeline misleads. See
|
||||
[06 · Reading the TRACE output](06-reading-the-trace.md).
|
||||
|
||||
## The four events
|
||||
|
||||
Everything above collapses into four events, in this order:
|
||||
|
||||
1. The `SecurityContext` is made available (filter 3)
|
||||
2. The request is protected from exploits (filters 4–5)
|
||||
3. The request is authenticated (filters 6–14)
|
||||
4. The request is authorized (filter 16, with 15 standing by to translate the refusal)
|
||||
|
||||
Where a custom filter goes is entirely a question of which of those four have already happened.
|
||||
That is [04 · Where custom filters land](04-where-custom-filters-land.md).
|
||||
|
||||
---
|
||||
|
||||
[← 01 · The two proxies](01-the-two-proxies.md) · **02 · The default chain** · [03 · The order table →](03-the-order-table.md)
|
||||
117
filter-chain/docs/03-the-order-table.md
Normal file
117
filter-chain/docs/03-the-order-table.md
Normal file
@@ -0,0 +1,117 @@
|
||||
[← 02 · The default chain](02-the-default-chain.md) · **03 · The order table** · [04 · Where custom filters land →](04-where-custom-filters-land.md)
|
||||
|
||||
# 03 · The order table
|
||||
|
||||
The order of the chain is not emergent and it is not the order you write your configuration in.
|
||||
It is a hard-coded table in a package-private class,
|
||||
`org.springframework.security.config.annotation.web.builders.FilterOrderRegistration`, built once
|
||||
in its constructor:
|
||||
|
||||
```java
|
||||
Step order = new Step(100, 100); // INITIAL_ORDER = 100, ORDER_STEP = 100
|
||||
put(DisableEncodeUrlFilter.class, order.next()); // 100
|
||||
put(ForceEagerSessionCreationFilter.class, order.next()); // 200
|
||||
…
|
||||
```
|
||||
|
||||
`Step.next()` returns the current value and then adds the step, so slots run **100, 200, 300 …**
|
||||
with 100 free numbers between neighbours. [`FilterOrderTable`](../src/main/java/com/ankurm/chain/support/FilterOrderTable.java)
|
||||
reads that map back by reflection rather than transcribing it, and
|
||||
[`demo1-order-table.txt`](output/demo1-order-table.txt) is its output for 7.1.1.
|
||||
|
||||
## The full table
|
||||
|
||||
```
|
||||
100 DisableEncodeUrlFilter 2200 OneTimeTokenAuthenticationFilter
|
||||
200 ForceEagerSessionCreationFilter 2300 -- reserved, nothing registered --
|
||||
300 ChannelProcessingFilter (removed) 2400 DefaultResourcesFilter
|
||||
400 HttpsRedirectFilter 2500 DefaultLoginPageGeneratingFilter
|
||||
500 -- reserved, nothing registered -- 2600 DefaultLogoutPageGeneratingFilter
|
||||
600 WebAsyncManagerIntegrationFilter 2700 DefaultOneTimeTokenSubmitPageGeneratingFilter
|
||||
700 SecurityContextHolderFilter 2800 ConcurrentSessionFilter
|
||||
800 SecurityContextPersistenceFilter 2900 DigestAuthenticationFilter
|
||||
900 HeaderWriterFilter 3000 BearerTokenAuthenticationFilter
|
||||
1000 CorsFilter (org.springframework.web) 3100 BasicAuthenticationFilter
|
||||
1100 CsrfFilter 3200 AuthenticationFilter
|
||||
1200 LogoutFilter 3300 RequestCacheAwareFilter
|
||||
1300 OAuth2AuthorizationRequestRedirectFilter 3400 SecurityContextHolderAwareRequestFilter
|
||||
1400 Saml2WebSsoAuthenticationRequestFilter 3500 JaasApiIntegrationFilter
|
||||
1500 GenerateOneTimeTokenFilter 3600 RememberMeAuthenticationFilter
|
||||
1600 X509AuthenticationFilter 3700 AnonymousAuthenticationFilter
|
||||
1700 AbstractPreAuthenticatedProcessingFilter 3800 OAuth2AuthorizationCodeGrantFilter
|
||||
1800 CasAuthenticationFilter 3900 SessionManagementFilter
|
||||
1900 OAuth2LoginAuthenticationFilter 4000 ExceptionTranslationFilter
|
||||
2000 Saml2WebSsoAuthenticationFilter 4100 FilterSecurityInterceptor (removed)
|
||||
2100 UsernamePasswordAuthenticationFilter 4200 AuthorizationFilter
|
||||
4300 SwitchUserFilter
|
||||
```
|
||||
|
||||
Forty-one registered slots, two reserved gaps, orders 100 through 4300.
|
||||
|
||||
## Two slots name classes that no longer exist
|
||||
|
||||
The map is keyed by **class-name string**, not by `Class`, because many of its entries live in
|
||||
optional modules — `oauth2-client`, `saml2-service-provider`, `cas`,
|
||||
`oauth2-resource-server` — that may not be on the classpath. Registering by name means the
|
||||
table can mention them without failing to load.
|
||||
|
||||
Two entries exploit that for a different reason. `ChannelProcessingFilter` (300) and
|
||||
`FilterSecurityInterceptor` (4100) are both `org.springframework.security.web` classes, and
|
||||
`spring-security-web` **is** on the classpath — but the classes are not in it:
|
||||
|
||||
```
|
||||
$ unzip -l spring-security-web-6.5.1.jar | grep -c 'access/intercept/FilterSecurityInterceptor.class'
|
||||
1
|
||||
$ unzip -l spring-security-web-7.1.1.jar | grep -c 'access/intercept/FilterSecurityInterceptor.class'
|
||||
0
|
||||
```
|
||||
|
||||
Both were removed in Spring Security 7.0. Their slots were left in the table so that no other
|
||||
number had to move — the 6.5.1 and 7.1.1 tables are byte-for-byte identical in content.
|
||||
`FilterOrderTableTest.removedFiltersStillOccupyTheirSlots` asserts exactly this.
|
||||
|
||||
The practical consequence is a compile error, not a runtime one. Every pre-7 tutorial that says
|
||||
|
||||
```java
|
||||
http.addFilterBefore(myFilter, FilterSecurityInterceptor.class);
|
||||
```
|
||||
|
||||
no longer compiles. The replacement anchor is `AuthorizationFilter.class`, one slot later at
|
||||
4200 — so a filter that was at 4099 is now at 4199, and if anything of yours sat between them,
|
||||
check it.
|
||||
|
||||
## Resolution walks up the superclass chain
|
||||
|
||||
`getOrder(Class)` does not do a map lookup and give up. It looks the class up, and on a miss
|
||||
takes `getSuperclass()` and tries again, until it runs out of superclasses:
|
||||
|
||||
```java
|
||||
for (Class<?> type = filterType; type != null; type = type.getSuperclass()) {
|
||||
Integer order = table.get(type.getName());
|
||||
if (order != null) return order;
|
||||
}
|
||||
return null;
|
||||
```
|
||||
|
||||
That is why `http.addFilter(myFilter)` — the single-argument form, with no anchor — works at all.
|
||||
It only works if some ancestor of your filter is in the table. A subclass of
|
||||
`UsernamePasswordAuthenticationFilter` inherits slot 2100 and needs no anchor. A subclass of
|
||||
`AbstractPreAuthenticatedProcessingFilter` inherits 1700.
|
||||
|
||||
`OncePerRequestFilter` is **not** in the table, which is what most people extend. So:
|
||||
|
||||
```java
|
||||
http.addFilter(new RequestIdFilter());
|
||||
// IllegalArgumentException: The Filter class com.ankurm.chain.filter.RequestIdFilter does not
|
||||
// have a registered order and cannot be added without a specified order. Consider using
|
||||
// addFilterBefore or addFilterAfter instead.
|
||||
```
|
||||
|
||||
(Message read out of `HttpSecurity`'s constant pool with `javap -v`, not paraphrased.)
|
||||
`addFilterBefore` and `addFilterAfter` raise the shorter sibling of that message,
|
||||
`The Filter class … does not have a registered order`, when the **anchor** is unknown. Use the
|
||||
two-argument form and name an anchor that is in the table.
|
||||
|
||||
---
|
||||
|
||||
[← 02 · The default chain](02-the-default-chain.md) · **03 · The order table** · [04 · Where custom filters land →](04-where-custom-filters-land.md)
|
||||
112
filter-chain/docs/04-where-custom-filters-land.md
Normal file
112
filter-chain/docs/04-where-custom-filters-land.md
Normal file
@@ -0,0 +1,112 @@
|
||||
[← 03 · The order table](03-the-order-table.md) · **04 · Where custom filters land** · [05 · Failure modes →](05-failure-modes.md)
|
||||
|
||||
# 04 · Where custom filters land
|
||||
|
||||
`HttpSecurity` gives you four ways to add a filter. Three of them take an anchor:
|
||||
|
||||
| Call | Order your filter gets |
|
||||
|---|---|
|
||||
| `addFilterBefore(f, Anchor.class)` | `order(Anchor) - 1` |
|
||||
| `addFilterAt(f, Anchor.class)` | `order(Anchor)` |
|
||||
| `addFilterAfter(f, Anchor.class)` | `order(Anchor) + 1` |
|
||||
| `addFilter(f)` | `order(f.getClass())`, resolved up the superclass chain |
|
||||
|
||||
All three anchored forms go through one private method:
|
||||
|
||||
```java
|
||||
private HttpSecurity addFilterAtOffsetOf(Filter filter, int offset, Class<? extends Filter> registeredFilter) {
|
||||
Integer registeredFilterOrder = this.filterOrders.getOrder(registeredFilter);
|
||||
if (registeredFilterOrder == null) { throw new IllegalArgumentException(…); }
|
||||
int order = registeredFilterOrder + offset;
|
||||
this.filters.add(new OrderedFilter(filter, order));
|
||||
this.filterOrders.put(filter.getClass(), order); // <- your filter is now an anchor too
|
||||
return this;
|
||||
}
|
||||
```
|
||||
|
||||
Four things follow from those seven lines, and none of them are documented.
|
||||
|
||||
## 1 · The offset is exactly ±1, not "the next free slot"
|
||||
|
||||
`addFilterBefore(f, CsrfFilter.class)` gives **1099**. It does not give 1050, or "somewhere
|
||||
between `HeaderWriterFilter` and `CsrfFilter`". Pinned by
|
||||
`FilterOrderTableTest.offsetsAreExactlyOne`.
|
||||
|
||||
## 2 · Your filter becomes an anchor
|
||||
|
||||
The last-but-one line registers *your* class at the order it just computed. So this works:
|
||||
|
||||
```java
|
||||
http.addFilterAfter(new ApiKeyFilter(), LogoutFilter.class) // 1201
|
||||
.addFilterAfter(new AuditFilter(), ApiKeyFilter.class); // 1202
|
||||
```
|
||||
|
||||
And so does the accident: add two instances of the same class against different anchors and the
|
||||
second `put` overwrites the first in the table. The chain is still correct — the `OrderedFilter`
|
||||
wrappers already hold their own numbers — but any *later* `addFilterBefore(x, ThatClass.class)`
|
||||
resolves against whichever registration happened last.
|
||||
|
||||
## 3 · The anchor does not have to be in the chain
|
||||
|
||||
`getOrder` consults the static table, not the chain being built. The `tie` profile calls
|
||||
`csrf().disable()` and then still adds filters *before* `CsrfFilter.class`:
|
||||
|
||||
```
|
||||
=== chain 1/12 (profile: tie)
|
||||
4/12 HeaderWriterFilter order=900
|
||||
5/12 MarkerFilterA order=? (not in the registration table)
|
||||
6/12 MarkerFilterB order=? (not in the registration table)
|
||||
7/12 LogoutFilter order=1200
|
||||
```
|
||||
|
||||
There is no `CsrfFilter`. The markers still land on 1099, between 900 and 1200. Asserted by
|
||||
`FilterChainContractTest.Tie.theAnchorNeedNotBePresent`. This is convenient — you can anchor to
|
||||
a filter you have disabled — and it is a trap, because "before the CSRF filter" stops meaning
|
||||
anything if someone later removes it and moves the surrounding filters.
|
||||
|
||||
## 4 · Two filters on the same anchor get the same number
|
||||
|
||||
`addFilterBefore(a, CsrfFilter.class)` and `addFilterBefore(b, CsrfFilter.class)` both produce
|
||||
1099. `performBuild()` sorts with
|
||||
|
||||
```java
|
||||
this.filters.sort(OrderComparator.INSTANCE);
|
||||
```
|
||||
|
||||
`List.sort` on an `ArrayList` is a stable sort, so equal orders keep insertion order — the order
|
||||
of your `addFilterBefore` calls. [`demo8-tie.txt`](output/demo8-tie.txt) shows both directions:
|
||||
|
||||
```
|
||||
--- A registered first ---
|
||||
executed order: [A, B]
|
||||
|
||||
--- B registered first (-DTIE_REVERSED=true), nothing else changed ---
|
||||
executed order: [B, A]
|
||||
```
|
||||
|
||||
That guarantee comes from `java.util.List`, not from Spring Security. Nothing in the API
|
||||
promises it. If the relative order of two custom filters matters, anchor the second to the
|
||||
first rather than relying on it.
|
||||
|
||||
## Where to put what
|
||||
|
||||
The four events from [02](02-the-default-chain.md#the-four-events) give the rule:
|
||||
|
||||
| Your filter is… | Anchor it after | Because by then |
|
||||
|---|---|---|
|
||||
| exploit protection (headers, tenancy, rate limits) | `SecurityContextHolderFilter` (701) | the context is available |
|
||||
| authentication | `LogoutFilter` (1201) | headers and CSRF have run, nothing has authenticated yet |
|
||||
| authorization, or anything that throws `AccessDeniedException` | `ExceptionTranslationFilter` (4001) | the principal is settled **and the throw will be translated** |
|
||||
|
||||
That last row differs from the reference documentation, which suggests
|
||||
`AnonymousAuthenticationFilter` (3701) for authorization filters. 3701 is below
|
||||
`ExceptionTranslationFilter` at 4000, so an `AccessDeniedException` thrown there is not
|
||||
translated. See [05 · Failure modes](05-failure-modes.md#a-denial-that-comes-back-as-500).
|
||||
|
||||
The `custom` profile puts one filter at each of those three placements plus the documented one,
|
||||
and [`demo5-custom-placement.txt`](output/demo5-custom-placement.txt) shows all four in the
|
||||
live chain.
|
||||
|
||||
---
|
||||
|
||||
[← 03 · The order table](03-the-order-table.md) · **04 · Where custom filters land** · [05 · Failure modes →](05-failure-modes.md)
|
||||
166
filter-chain/docs/05-failure-modes.md
Normal file
166
filter-chain/docs/05-failure-modes.md
Normal file
@@ -0,0 +1,166 @@
|
||||
[← 04 · Where custom filters land](04-where-custom-filters-land.md) · **05 · Failure modes** · [06 · Reading the TRACE output →](06-reading-the-trace.md)
|
||||
|
||||
# 05 · Failure modes
|
||||
|
||||
Six ways a filter chain goes wrong. Every one of them is reproducible in this module and every
|
||||
transcript below is a real run.
|
||||
|
||||
## An authentication filter after `AuthorizationFilter`
|
||||
|
||||
Profile `misordered`, transcript [`demo7-misordered.txt`](output/demo7-misordered.txt).
|
||||
|
||||
The same `ApiKeyAuthenticationFilter` as the working `custom` profile, anchored to
|
||||
`AuthorizationFilter` instead of `LogoutFilter` — order 4201 instead of 1201:
|
||||
|
||||
```
|
||||
11/12 AuthorizationFilter order=4200
|
||||
12/12 ApiKeyAuthenticationFilter order=? (not in the registration table)
|
||||
|
||||
$ curl -i -H "X-Api-Key: let-me-in" localhost:8080/whoami
|
||||
HTTP/1.1 401
|
||||
```
|
||||
|
||||
The key is valid. The filter is in the chain and demonstrably executes — it is the last thing
|
||||
that runs. It sets the `SecurityContext` half a millisecond after the authorization decision was
|
||||
already taken against the anonymous principal.
|
||||
|
||||
**Fingerprint:** a 401 or 403 with correct credentials, no exception, and TRACE showing your
|
||||
filter running *after* `AuthorizationFilter (n/n)`. Anchor authentication filters to
|
||||
`LogoutFilter`.
|
||||
|
||||
## A denial that comes back as 500
|
||||
|
||||
Profile `custom`, transcript [`demo6-exception-translation.txt`](output/demo6-exception-translation.txt).
|
||||
|
||||
`ExceptionTranslationFilter` (4000) is what turns `AccessDeniedException` into a 403 and
|
||||
`AuthenticationException` into a 401 or a redirect. It does that by wrapping the *rest of the
|
||||
chain* in a try/catch. A filter that throws from **below** 4000 is not inside that try block:
|
||||
|
||||
```
|
||||
$ curl -i -u alice:password localhost:8080/tenant/doc # filter at order 3701
|
||||
HTTP/1.1 500
|
||||
|
||||
$ curl -i -u alice:password localhost:8080/tenant/translated # filter at order 4001
|
||||
HTTP/1.1 403
|
||||
```
|
||||
|
||||
Identical filter code. Identical exception. 300 order slots apart.
|
||||
|
||||
3701 is the placement the reference documentation's own `TenantFilter` example uses
|
||||
(`addFilterAfter(new TenantFilter(), AnonymousAuthenticationFilter.class)`), and the example
|
||||
throws `AccessDeniedException`. If you follow it literally you get a 500.
|
||||
|
||||
**Fingerprint:** a stack trace in the container log with `AccessDeniedException` at the top and
|
||||
no Spring Security frames below your own filter.
|
||||
|
||||
## The second instance of one `OncePerRequestFilter` subclass never runs
|
||||
|
||||
This one cost a test failure to find, and it is the reason the transcript above needs a JVM flag.
|
||||
|
||||
`OncePerRequestFilter` guards against double execution with a request attribute named
|
||||
`getFilterName() + ".FILTERED"`. For a filter that is not a Spring bean, `getFilterName()` falls
|
||||
back to the **class name**. Two instances of the same subclass therefore answer the same
|
||||
attribute name, and the second one to run finds it already set and skips itself entirely:
|
||||
|
||||
```
|
||||
$ curl -i -u alice:password localhost:8080/tenant/translated # filter at order 4001
|
||||
HTTP/1.1 200 <- with both TenantFilters sharing one key
|
||||
HTTP/1.1 403 <- with -DUNIQUE_ONCE_KEY=true
|
||||
```
|
||||
|
||||
Both filters are in the chain either way — [`demo5-custom-placement.txt`](output/demo5-custom-placement.txt)
|
||||
lists `TenantFilter` at positions 17 and 19. One of them is inert. There is no log line for it
|
||||
at any level.
|
||||
|
||||
**Fingerprint:** a filter that is visibly in the chain and visibly does nothing. Override
|
||||
`getAlreadyFilteredAttributeName()`, or give each instance its own class, or register them as
|
||||
beans with distinct names.
|
||||
|
||||
## A custom filter that runs twice
|
||||
|
||||
Profile `doublereg`, transcript [`demo9-double-registration.txt`](output/demo9-double-registration.txt).
|
||||
|
||||
Spring Boot registers every `jakarta.servlet.Filter` **bean** with the servlet container. Add
|
||||
the same bean to the security chain and it is in two chains at once:
|
||||
|
||||
```
|
||||
--- profile: doublereg ---
|
||||
X-Counting-Filter-Invocations: 2
|
||||
countingFilter com.ankurm.chain.filter.CountingFilter urls=[/*]
|
||||
|
||||
--- profile: doublereg,fixed (FilterRegistrationBean.setEnabled(false)) ---
|
||||
X-Counting-Filter-Invocations: 1
|
||||
(not registered with the container)
|
||||
```
|
||||
|
||||
Extending `OncePerRequestFilter` **hides** this rather than fixing it: the second pass
|
||||
short-circuits, the counter reads 1, and you conclude there is no problem. The filter is still
|
||||
registered twice, still wraps every request the container serves — including paths no
|
||||
`SecurityFilterChain` matches, and including the internal `/error` dispatch.
|
||||
|
||||
**Fix:** declare a `FilterRegistrationBean` for it with `setEnabled(false)`, or do not make the
|
||||
filter a bean at all and construct it inline in the `SecurityFilterChain` method.
|
||||
|
||||
## Two chains where the broad one shadows the narrow one
|
||||
|
||||
Profile `multichain`, transcript [`demo10-multichain.txt`](output/demo10-multichain.txt).
|
||||
|
||||
`FilterChainProxy` invokes the **first** matching chain and stops. A chain matching `any request`
|
||||
declared ahead of a chain matching `/api/**` makes the second one dead code.
|
||||
|
||||
`WebSecurityFilterChainValidator` catches the blatant version of this at startup and throws
|
||||
`UnreachableFilterChainException`:
|
||||
|
||||
> A filter chain that matches any request [`…`] has already been configured, which means that
|
||||
> this filter chain [`…`] will never get invoked. Please use `HttpSecurity#securityMatcher` to
|
||||
> ensure that there is only one filter chain configured for 'any request' and that the 'any
|
||||
> request' filter chain is published last.
|
||||
|
||||
(Message read out of the class's constant pool. The validator and the exception are present in
|
||||
both 6.5.1 and 7.1.1, so this is not a 7.x change.) It runs three checks —
|
||||
`checkForAnyRequestRequestMatcher`, `checkForDuplicateMatchers`, `checkAuthorizationFilters` —
|
||||
and none of them catch the common case: two *specific* matchers that overlap partially, where
|
||||
the broader one is declared first. Nothing warns about that.
|
||||
|
||||
Order chains explicitly with `@Order`, narrowest first. A bean with no `@Order` gets
|
||||
`Ordered.LOWEST_PRECEDENCE`, and two of those have no defined relative order at all.
|
||||
|
||||
## `ignoring()` where you meant `permitAll()`
|
||||
|
||||
Profile `ignoring`, transcript [`demo11-ignoring-vs-permitall.txt`](output/demo11-ignoring-vs-permitall.txt).
|
||||
|
||||
```
|
||||
=== chain 1/2 matcher = PathPattern [/static/**] (0 filters)
|
||||
<no filters> - this chain does NOTHING.
|
||||
```
|
||||
|
||||
A `SecurityFilterChain` with zero filters is a real, matched chain that runs nothing. The
|
||||
response proves it:
|
||||
|
||||
```
|
||||
$ curl -sD- -o /dev/null localhost:8080/static/asset.txt
|
||||
HTTP/1.1 200
|
||||
<- no security headers at all
|
||||
|
||||
$ curl -sD- -o /dev/null localhost:8080/public/hello
|
||||
HTTP/1.1 200
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
```
|
||||
|
||||
Spring Security itself warns about this at startup:
|
||||
|
||||
```
|
||||
WARN o.s.s.c.a.web.builders.WebSecurity : You are asking Spring Security to ignore
|
||||
PathPattern [/static/**]. This is not recommended -- please use permitAll via
|
||||
HttpSecurity#authorizeHttpRequests instead.
|
||||
```
|
||||
|
||||
`ignoring()` is worth it only when the path genuinely serves static bytes and the header cost
|
||||
matters. Anything behind it is outside Spring Security, including a path traversal that resolves
|
||||
somewhere interesting.
|
||||
|
||||
---
|
||||
|
||||
[← 04 · Where custom filters land](04-where-custom-filters-land.md) · **05 · Failure modes** · [06 · Reading the TRACE output →](06-reading-the-trace.md)
|
||||
163
filter-chain/docs/06-reading-the-trace.md
Normal file
163
filter-chain/docs/06-reading-the-trace.md
Normal file
@@ -0,0 +1,163 @@
|
||||
[← 05 · Failure modes](05-failure-modes.md) · **06 · Reading the TRACE output** · [07 · Multiple chains →](07-multiple-chains.md)
|
||||
|
||||
# 06 · Reading the TRACE output
|
||||
|
||||
```properties
|
||||
logging.level.org.springframework.security=TRACE
|
||||
```
|
||||
|
||||
That one line is worth more than every diagram of the filter chain, including the ones in this
|
||||
repository. It is also actively misleading in three specific ways, and this chapter is mostly
|
||||
about those.
|
||||
|
||||
The full transcripts are [`demo3-trace-authenticated.txt`](output/demo3-trace-authenticated.txt)
|
||||
(a request that succeeds) and [`demo4-trace-csrf-403.txt`](output/demo4-trace-csrf-403.txt)
|
||||
(one that does not).
|
||||
|
||||
## The five lines that matter
|
||||
|
||||
**Chain selection.** Before anything runs:
|
||||
|
||||
```
|
||||
TRACE FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as
|
||||
'baseline' in [class path resource [com/ankurm/chain/config/BaselineSecurityConfig.class]]
|
||||
matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, …] (1/1)
|
||||
```
|
||||
|
||||
This is the single most useful line in the file and almost nobody knows it exists. It names the
|
||||
**bean** and the **class that declared it**. If you have four `SecurityFilterChain` beans and a
|
||||
request is behaving as though it hit the wrong one, this line settles it in one grep. The
|
||||
trailing `(1/1)` is the chain's position in `FilterChainProxy`'s list.
|
||||
|
||||
**Entry.** `DEBUG FilterChainProxy : Securing GET /whoami`
|
||||
|
||||
**Each filter.** `TRACE FilterChainProxy : Invoking CsrfFilter (5/16)`
|
||||
|
||||
The `(n/m)` counters are positions in the same list `/diag/chains` prints, so the two line up
|
||||
exactly. A filter that never appears here is not in the chain.
|
||||
|
||||
**Exit.** `DEBUG FilterChainProxy : Secured GET /whoami` — meaning the chain completed and
|
||||
handed off to the servlet. It does **not** mean the request succeeded.
|
||||
|
||||
**Startup.** Once per chain, at DEBUG, on `DefaultSecurityFilterChain`:
|
||||
|
||||
```
|
||||
DEBUG o.s.s.web.DefaultSecurityFilterChain : Will secure any request with filters:
|
||||
DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, …
|
||||
```
|
||||
|
||||
Note the wording: `with filters: …`, not `with [ … ]`. The bracketed form appears in the
|
||||
reference documentation and in most blog posts; it is not what 7.1.1 prints, and it is not what
|
||||
6.5.1 printed either.
|
||||
|
||||
## Misleading thing 1 · the log is not a timeline
|
||||
|
||||
Look at the end of the successful request:
|
||||
|
||||
```
|
||||
TRACE FilterChainProxy : Invoking AnonymousAuthenticationFilter (14/16)
|
||||
TRACE FilterChainProxy : Invoking ExceptionTranslationFilter (15/16)
|
||||
TRACE FilterChainProxy : Invoking AuthorizationFilter (16/16)
|
||||
TRACE RequestMatcherDelegatingAuthorizationManager : Authorizing GET /whoami
|
||||
TRACE RequestMatcherDelegatingAuthorizationManager : Checking authorization on GET /whoami …
|
||||
TRACE AnonymousAuthenticationFilter : Did not set SecurityContextHolder since already authenticated …
|
||||
```
|
||||
|
||||
`AnonymousAuthenticationFilter` logs its decision **two filters after it was invoked**. It is not
|
||||
a threading artefact. The filter installs a lazily-resolved `Supplier<SecurityContext>` and the
|
||||
supplier only runs when something asks for the authentication — which, here, is
|
||||
`AuthorizationFilter` at 16/16. The log line fires at resolution time.
|
||||
|
||||
The same effect appears earlier and matters more:
|
||||
|
||||
```
|
||||
TRACE FilterChainProxy : Invoking SecurityContextHolderFilter (3/16)
|
||||
…
|
||||
TRACE FilterChainProxy : Invoking BasicAuthenticationFilter (11/16)
|
||||
TRACE BasicAuthenticationFilter : Found username 'alice' in Basic Authorization header
|
||||
TRACE HttpSessionSecurityContextRepository : No HttpSession currently exists
|
||||
TRACE SupplierDeferredSecurityContext : Created SecurityContextImpl [Null authentication]
|
||||
```
|
||||
|
||||
The session lookup is logged under filter **11**, not filter 3. `SecurityContextHolderFilter`
|
||||
installed the supplier; `BasicAuthenticationFilter` was the first code to dereference it. If you
|
||||
are reading the log to work out *when* the session was touched, filter 3 is the wrong answer.
|
||||
|
||||
## Misleading thing 2 · `SupplierDeferredSecurityContext : Created …` appears twice
|
||||
|
||||
Every request logs that line two or three times. It is not two contexts and it is not a bug —
|
||||
`SupplierDeferredSecurityContext` logs on each `getContext()` that finds nothing stored. Do not
|
||||
read it as evidence that something is creating contexts repeatedly.
|
||||
|
||||
## Misleading thing 3 · the rejected request runs the chain twice
|
||||
|
||||
The 403 transcript is the important one. After `CsrfFilter` rejects:
|
||||
|
||||
```
|
||||
DEBUG CsrfFilter : Invalid CSRF token found for http://localhost:8080/hello
|
||||
DEBUG AccessDeniedHandlerImpl : Responding with 403 status code
|
||||
DEBUG o.a.c.c.C.[Tomcat].[localhost] : Processing ErrorPage[errorCode=0, location=/error]
|
||||
TRACE FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as 'baseline' …
|
||||
DEBUG FilterChainProxy : Securing GET /error
|
||||
TRACE FilterChainProxy : Invoking DisableEncodeUrlFilter (1/16)
|
||||
…
|
||||
TRACE FilterChainProxy : Invoking AuthorizationFilter (16/16)
|
||||
TRACE RequestMatcherDelegatingAuthorizationManager : Authorizing GET /error
|
||||
```
|
||||
|
||||
The container dispatches to `/error`, and the **entire sixteen-filter chain runs again** for that
|
||||
dispatch. Two things about the second pass:
|
||||
|
||||
- **Half the filters invoke and then do nothing.** `OncePerRequestFilter.doFilter` starts with
|
||||
`skipDispatch(request)`, which returns `true` when the request carries the
|
||||
`jakarta.servlet.error.request_uri` attribute and `shouldNotFilterErrorDispatch()` says so —
|
||||
and that method returns `true` by default. Of the sixteen default filters, six extend
|
||||
`OncePerRequestFilter` (`DisableEncodeUrlFilter`, `WebAsyncManagerIntegrationFilter`,
|
||||
`HeaderWriterFilter`, `CsrfFilter`, `DefaultLogoutPageGeneratingFilter`,
|
||||
`BasicAuthenticationFilter`) and pass straight through. The other ten extend
|
||||
`GenericFilterBean` and run in full. You can see it in the transcript: on the second pass
|
||||
`LogoutFilter` still logs `Did not match request to PathPattern [POST /logout]`, while
|
||||
`BasicAuthenticationFilter (11/16)` logs nothing at all.
|
||||
- **So the error dispatch is authorized, but not authenticated.** `AuthorizationFilter` extends
|
||||
`GenericFilterBean` and runs; `BasicAuthenticationFilter` extends `OncePerRequestFilter` and
|
||||
does not. `GET /error` is therefore matched against `authorizeHttpRequests` on its own merits
|
||||
as an **anonymous** request, and under `.anyRequest().authenticated()` it is denied. The trace
|
||||
ends with `ExceptionTranslationFilter : Sending AnonymousAuthenticationToken … to
|
||||
authentication entry point since access is denied` and an `AuthorizationDeniedException`
|
||||
stack trace.
|
||||
- The `(1/16) … (16/16)` counters restart, so a casual read of the log looks like the original
|
||||
request ran twice.
|
||||
|
||||
This is the source of a whole family of confusing reports: a 403 that logs a 401 underneath it,
|
||||
an `/error` page that itself 403s, a custom filter that "runs twice" for failed requests only.
|
||||
Permitting `/error` explicitly is the usual fix, and it is worth doing deliberately rather than
|
||||
discovering it.
|
||||
|
||||
## Stack traces are twice as tall as you expect
|
||||
|
||||
```
|
||||
at org.springframework.security.web.access.intercept.AuthorizationFilter.doFilter(AuthorizationFilter.java:99)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(…)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(…)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(…)
|
||||
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(…)
|
||||
```
|
||||
|
||||
Three decorator frames between every pair of real filters. `FilterChainProxy` decorates the chain
|
||||
with `ObservationFilterChainDecorator` whenever a usable `ObservationRegistry` bean exists — which
|
||||
in Boot means whenever the actuator starter is present. Without it you get the plain
|
||||
`VirtualFilterChain` and shorter traces. Nothing is wrong; the frames are just noise, and knowing
|
||||
they are noise makes the trace readable.
|
||||
|
||||
## What to grep
|
||||
|
||||
```bash
|
||||
grep 'Securing\|Secured' app.log # request boundaries
|
||||
grep 'Trying to match request against' app.log # which chain, and which bean declared it
|
||||
grep 'Invoking' app.log | tail -30 # the chain as it actually ran
|
||||
grep -A2 'Invalid CSRF\|Access is denied\|Failed to' app.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
[← 05 · Failure modes](05-failure-modes.md) · **06 · Reading the TRACE output** · [07 · Multiple chains →](07-multiple-chains.md)
|
||||
71
filter-chain/docs/07-multiple-chains.md
Normal file
71
filter-chain/docs/07-multiple-chains.md
Normal file
@@ -0,0 +1,71 @@
|
||||
[← 06 · Reading the TRACE output](06-reading-the-trace.md) · **07 · Multiple chains** · [08 · Debugging recipes →](08-debugging-recipes.md)
|
||||
|
||||
# 07 · Multiple chains
|
||||
|
||||
`FilterChainProxy` holds a list. It walks it in order, asks each chain `matches(request)`, and
|
||||
invokes **the first one that says yes**. There is no fall-through and no combining.
|
||||
|
||||
The `multichain` profile declares three, and
|
||||
[`demo10-multichain.txt`](output/demo10-multichain.txt) shows what they cost:
|
||||
|
||||
| Chain | `@Order` | Matcher | Filters |
|
||||
|---|---|---|---|
|
||||
| API | 1 | `/api/**` | 12 |
|
||||
| Diagnostics / public | 2 | `/diag/**`, `/public/**` | 10 |
|
||||
| Browser | 3 | any request | 15 |
|
||||
|
||||
## Shorter is the point
|
||||
|
||||
The API chain drops `CsrfFilter`, `UsernamePasswordAuthenticationFilter`,
|
||||
`DefaultResourcesFilter`, `DefaultLoginPageGeneratingFilter` and
|
||||
`DefaultLogoutPageGeneratingFilter` — five filters that have no meaning for a token client and
|
||||
five filters that cannot surprise you at three in the morning. A stateless API served by the
|
||||
browser chain is the origin of most "why is my POST getting a 403" questions.
|
||||
|
||||
One counter-intuitive detail: asking for `SessionCreationPolicy.STATELESS` **adds** a filter.
|
||||
`SessionManagementFilter` (3900) is absent from the default chain and appears only when you
|
||||
configure `sessionManagement(…)` at all. Statelessness is enforced by a filter, not by an absence
|
||||
of one.
|
||||
|
||||
## Ordering
|
||||
|
||||
`@Order` on the bean fixes evaluation order. Narrowest matcher first; the `any request` chain
|
||||
last. A bean with no `@Order` gets `Ordered.LOWEST_PRECEDENCE`, and two such beans have no
|
||||
defined order relative to each other — which makes the behaviour depend on bean-definition
|
||||
ordering, which depends on classpath scanning order.
|
||||
|
||||
`WebSecurityFilterChainValidator` catches the blatant failure at startup and throws
|
||||
`UnreachableFilterChainException` when an `any request` chain is declared before another chain.
|
||||
It does not catch two specific matchers that partially overlap. See
|
||||
[05 · Failure modes](05-failure-modes.md#two-chains-where-the-broad-one-shadows-the-narrow-one).
|
||||
|
||||
## `securityMatcher` is not `requestMatchers`
|
||||
|
||||
They read similarly and do different jobs, and mixing them up produces a chain that is either
|
||||
inert or catches everything:
|
||||
|
||||
- **`http.securityMatcher(…)`** — decides whether **this chain** handles the request at all. It
|
||||
is the chain's `RequestMatcher`, evaluated by `FilterChainProxy` before any filter runs.
|
||||
- **`authorizeHttpRequests(a -> a.requestMatchers(…)…)`** — decides what
|
||||
`AuthorizationFilter` does **once the chain is already running**.
|
||||
|
||||
A chain with no `securityMatcher` matches any request. Two of those and the second is dead.
|
||||
|
||||
## `ignoring()` versus `permitAll()`
|
||||
|
||||
[`demo11-ignoring-vs-permitall.txt`](output/demo11-ignoring-vs-permitall.txt) puts them side by
|
||||
side. `WebSecurity.ignoring()` produces a genuine `SecurityFilterChain` with **zero filters**:
|
||||
|
||||
```
|
||||
=== chain 1/2 matcher = PathPattern [/static/**] (0 filters)
|
||||
<no filters> - this chain does NOTHING.
|
||||
```
|
||||
|
||||
and the response carries no `X-Content-Type-Options`, no cache headers, nothing. `permitAll()`
|
||||
runs all sixteen filters and then authorizes. Spring Security warns about `ignoring()` at
|
||||
startup and the warning is worth heeding: the saving is a few microseconds, and the cost is that
|
||||
the path is outside Spring Security entirely — including the `HttpFirewall`.
|
||||
|
||||
---
|
||||
|
||||
[← 06 · Reading the TRACE output](06-reading-the-trace.md) · **07 · Multiple chains** · [08 · Debugging recipes →](08-debugging-recipes.md)
|
||||
101
filter-chain/docs/08-debugging-recipes.md
Normal file
101
filter-chain/docs/08-debugging-recipes.md
Normal file
@@ -0,0 +1,101 @@
|
||||
[← 07 · Multiple chains](07-multiple-chains.md) · **08 · Debugging recipes** · [09 · Testing the chain →](09-testing-the-chain.md)
|
||||
|
||||
# 08 · Debugging recipes
|
||||
|
||||
Ordered by how often they settle the question.
|
||||
|
||||
## 1 · Print the live chain
|
||||
|
||||
[`DiagnosticsController`](../src/main/java/com/ankurm/chain/web/DiagnosticsController.java) is
|
||||
about forty lines and answers most filter questions outright:
|
||||
|
||||
```java
|
||||
@GetMapping(value = "/diag/chains", produces = "text/plain")
|
||||
public String chains() {
|
||||
for (SecurityFilterChain chain : this.filterChainProxy.getFilterChains()) {
|
||||
// chain.getFilters() is the real, ordered list
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
It reads the live `FilterChainProxy` bean, so it cannot disagree with reality the way a diagram
|
||||
can. Copy it into any application you are debugging.
|
||||
|
||||
**Delete it before shipping.** It publishes your entire security topology, including which paths
|
||||
have which chains and how many filters each one has.
|
||||
|
||||
## 2 · Turn on TRACE and read the chain-selection line
|
||||
|
||||
```properties
|
||||
logging.level.org.springframework.security=TRACE
|
||||
```
|
||||
|
||||
```bash
|
||||
grep 'Trying to match request against' app.log
|
||||
```
|
||||
|
||||
That line names the bean and the class that declared the chain that served the request. When a
|
||||
request is behaving as though a different chain handled it, this is the fastest way to find out
|
||||
that it did. [06 · Reading the TRACE output](06-reading-the-trace.md) covers the rest, including
|
||||
three ways the log misleads.
|
||||
|
||||
## 3 · Read the startup line before you read anything else
|
||||
|
||||
```bash
|
||||
grep 'Will secure' app.log
|
||||
```
|
||||
|
||||
One line per chain, printed at DEBUG on `DefaultSecurityFilterChain`, listing every filter in
|
||||
order. If a filter you added is not in that line, nothing else you investigate matters.
|
||||
|
||||
## 4 · Ask what order a filter would get
|
||||
|
||||
```java
|
||||
FilterOrderTable.orderOf(CsrfFilter.class); // 1100
|
||||
FilterOrderTable.orderIfAddedBefore(CsrfFilter.class); // 1099
|
||||
```
|
||||
|
||||
[`FilterOrderTable`](../src/main/java/com/ankurm/chain/support/FilterOrderTable.java) reflects
|
||||
the real `FilterOrderRegistration`. `GET /diag/order` prints the whole table, marking slots whose
|
||||
class is not on the classpath. Useful when you are choosing an anchor and want to know what sits
|
||||
between it and the next real filter.
|
||||
|
||||
## 5 · Check for double registration
|
||||
|
||||
```bash
|
||||
curl -s localhost:8080/diag/servlet-filters
|
||||
```
|
||||
|
||||
Any custom filter that appears in both this list and `/diag/chains` runs twice.
|
||||
[05 · Failure modes](05-failure-modes.md#a-custom-filter-that-runs-twice) has the fix.
|
||||
|
||||
## 6 · When a filter is in the chain and does nothing
|
||||
|
||||
Two candidates, in order of likelihood:
|
||||
|
||||
1. It extends `OncePerRequestFilter` and something else with the same
|
||||
`getAlreadyFilteredAttributeName()` already ran — usually another instance of the same class.
|
||||
2. It extends `OncePerRequestFilter` and the request is an `ERROR` or `ASYNC` dispatch, where
|
||||
`shouldNotFilterErrorDispatch()` / `shouldNotFilterAsyncDispatch()` default to skipping.
|
||||
|
||||
Neither logs anything at any level. Put a `System.out.println` at the top of `doFilterInternal`
|
||||
before concluding the filter is not in the chain — because it is.
|
||||
|
||||
## 7 · The symptom table
|
||||
|
||||
| Symptom | Look at |
|
||||
|---|---|
|
||||
| 401/403 with correct credentials | Is your auth filter after `AuthorizationFilter`? [05](05-failure-modes.md#an-authentication-filter-after-authorizationfilter) |
|
||||
| 500 where you expected 403 | Are you throwing below `ExceptionTranslationFilter` (4000)? [05](05-failure-modes.md#a-denial-that-comes-back-as-500) |
|
||||
| Filter runs twice | Is it a `@Bean`? [05](05-failure-modes.md#a-custom-filter-that-runs-twice) |
|
||||
| Filter never runs, but is in the chain | `OncePerRequestFilter` key collision. [05](05-failure-modes.md#the-second-instance-of-one-onceperrequestfilter-subclass-never-runs) |
|
||||
| Log seems to show the request twice | The `/error` dispatch. [06](06-reading-the-trace.md#misleading-thing-3--the-rejected-request-runs-the-chain-twice) |
|
||||
| A path gets no security headers | `ignoring()`. [07](07-multiple-chains.md#ignoring-versus-permitall) |
|
||||
| The wrong chain handled it | `grep 'Trying to match request against'` |
|
||||
| `RequestRejectedException` from nowhere | `HttpFirewall`, before any chain. [01](01-the-two-proxies.md#filterchainproxy) |
|
||||
| Two custom filters in the wrong order | Same anchor, same order number. [04](04-where-custom-filters-land.md#4--two-filters-on-the-same-anchor-get-the-same-number) |
|
||||
| `FilterSecurityInterceptor` will not compile | Removed in 7.0; use `AuthorizationFilter`. [03](03-the-order-table.md#two-slots-name-classes-that-no-longer-exist) |
|
||||
|
||||
---
|
||||
|
||||
[← 07 · Multiple chains](07-multiple-chains.md) · **08 · Debugging recipes** · [09 · Testing the chain →](09-testing-the-chain.md)
|
||||
83
filter-chain/docs/09-testing-the-chain.md
Normal file
83
filter-chain/docs/09-testing-the-chain.md
Normal file
@@ -0,0 +1,83 @@
|
||||
[← 08 · Debugging recipes](08-debugging-recipes.md) · **09 · Testing the chain** · [docs index →](README.md)
|
||||
|
||||
# 09 · Testing the chain
|
||||
|
||||
Twenty-one assertions, in
|
||||
[`FilterOrderTableTest`](../src/test/java/com/ankurm/chain/FilterOrderTableTest.java) and
|
||||
[`FilterChainContractTest`](../src/test/java/com/ankurm/chain/FilterChainContractTest.java).
|
||||
Output in [`docs/output/tests.txt`](output/tests.txt).
|
||||
|
||||
## Assert the chain, not the happy path
|
||||
|
||||
```java
|
||||
@Autowired FilterChainProxy proxy;
|
||||
|
||||
assertThat(proxy.getFilterChains().get(0).getFilters().stream()
|
||||
.map((filter) -> filter.getClass().getSimpleName()).toList())
|
||||
.containsExactly("DisableEncodeUrlFilter", …, "AuthorizationFilter");
|
||||
```
|
||||
|
||||
`containsExactly` on the whole list is deliberately brittle. A Spring Security upgrade that adds
|
||||
or removes a filter should fail this test loudly rather than change behaviour quietly — that is
|
||||
what the test is for. `containsSubsequence` is the right tool for a claim about *relative*
|
||||
placement:
|
||||
|
||||
```java
|
||||
assertThat(names).containsSubsequence("LogoutFilter", "ApiKeyAuthenticationFilter",
|
||||
"UsernamePasswordAuthenticationFilter");
|
||||
```
|
||||
|
||||
## One nested class per profile
|
||||
|
||||
Profiles are fixed at context startup, so a scenario is a `@Nested` class with its own
|
||||
`@ActiveProfiles`. Spring's test context caching means the seven contexts in this suite are each
|
||||
built once and the whole run takes about eight seconds.
|
||||
|
||||
```java
|
||||
@Nested @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("misordered")
|
||||
class Misordered { … }
|
||||
```
|
||||
|
||||
Boot 4 moved the test slices: `@AutoConfigureMockMvc` is
|
||||
`org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc` and lives in
|
||||
`spring-boot-starter-webmvc-test`; the security post-processors need
|
||||
`spring-boot-starter-security-test`. `spring-boot-starter-test` alone is not enough.
|
||||
|
||||
## Assert the number, not the position
|
||||
|
||||
```java
|
||||
assertThat(FilterOrderTable.orderIfAddedBefore(CsrfFilter.class)).isEqualTo(1099);
|
||||
```
|
||||
|
||||
Position in the chain moves whenever anything else is added. The order *number* is a property of
|
||||
the framework and only moves on an upgrade — which is exactly when you want to be told.
|
||||
|
||||
## Assert what is absent
|
||||
|
||||
```java
|
||||
assertThat(names).doesNotContain("SessionManagementFilter", "SecurityContextPersistenceFilter");
|
||||
assertThat(FilterOrderTable.isOnClasspath(
|
||||
"org.springframework.security.web.access.intercept.FilterSecurityInterceptor")).isFalse();
|
||||
```
|
||||
|
||||
Half the useful facts about the default chain are negative ones.
|
||||
|
||||
## MockMvc shows you the throw, not the status
|
||||
|
||||
The 500 in [`demo6`](output/demo6-exception-translation.txt) has no MockMvc equivalent, because
|
||||
MockMvc has no container to render an error page. It rethrows instead, which is the same fact
|
||||
from the other side:
|
||||
|
||||
```java
|
||||
assertThat(catchThrowable(() -> mvc.perform(get("/tenant/doc").with(user()))))
|
||||
.isInstanceOf(AccessDeniedException.class)
|
||||
.hasMessageContaining("doc-placement");
|
||||
```
|
||||
|
||||
Note `isInstanceOf`, not `rootCause().isInstanceOf` — the exception arrives with no cause, having
|
||||
been thrown by a filter and never wrapped. Getting that wrong is what surfaced the finding in
|
||||
[05](05-failure-modes.md#the-second-instance-of-one-onceperrequestfilter-subclass-never-runs).
|
||||
|
||||
---
|
||||
|
||||
[← 08 · Debugging recipes](08-debugging-recipes.md) · **09 · Testing the chain** · [docs index →](README.md)
|
||||
39
filter-chain/docs/README.md
Normal file
39
filter-chain/docs/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Documentation index — `filter-chain`
|
||||
|
||||
Nine chapters, in order. Each links to the next; each links to the source files and the captured
|
||||
output it draws on. Nothing here is asserted without a file behind it.
|
||||
|
||||
| # | Chapter | What it answers |
|
||||
|---|---|---|
|
||||
| 01 | [The two proxies](01-the-two-proxies.md) | Why Spring Security is one servlet filter, and what `DelegatingFilterProxy`, `FilterChainProxy` and `SecurityFilterChain` each do |
|
||||
| 02 | [The default chain](02-the-default-chain.md) | The sixteen filters of the reference configuration, one by one, and what breaks without each |
|
||||
| 03 | [The order table](03-the-order-table.md) | `FilterOrderRegistration`: every slot from 100 to 4300, the two reserved gaps, and the two slots naming classes that no longer exist |
|
||||
| 04 | [Where custom filters land](04-where-custom-filters-land.md) | `addFilterBefore` / `After` / `At` / `addFilter`, the ±1 offset, superclass resolution, and ties |
|
||||
| 05 | [Failure modes](05-failure-modes.md) | Six ways a chain goes wrong, each reproducible here |
|
||||
| 06 | [Reading the TRACE output](06-reading-the-trace.md) | The five lines that matter and the three ways the log misleads |
|
||||
| 07 | [Multiple chains](07-multiple-chains.md) | Matching, ordering, `securityMatcher` vs `requestMatchers`, `ignoring()` vs `permitAll()` |
|
||||
| 08 | [Debugging recipes](08-debugging-recipes.md) | Seven recipes and a symptom → chapter table |
|
||||
| 09 | [Testing the chain](09-testing-the-chain.md) | How the twenty-one assertions are written and why |
|
||||
|
||||
## Captured output
|
||||
|
||||
Every file below is regenerated by [`../scripts/run-all.sh`](../scripts/run-all.sh) from a real
|
||||
run. None of it is typed by hand.
|
||||
|
||||
| File | Scenario |
|
||||
|---|---|
|
||||
| [`demo1-order-table.txt`](output/demo1-order-table.txt) | `FilterOrderRegistration` reflected out of the jar |
|
||||
| [`demo2-default-chain.txt`](output/demo2-default-chain.txt) | The live 16-filter chain, plus the startup DEBUG line |
|
||||
| [`demo3-trace-authenticated.txt`](output/demo3-trace-authenticated.txt) | TRACE for one authenticated `GET` |
|
||||
| [`demo4-trace-csrf-403.txt`](output/demo4-trace-csrf-403.txt) | TRACE for a CSRF rejection, including the `/error` re-dispatch |
|
||||
| [`demo5-custom-placement.txt`](output/demo5-custom-placement.txt) | Four custom filters at four anchors |
|
||||
| [`demo6-exception-translation.txt`](output/demo6-exception-translation.txt) | 500 vs 403 across the `ExceptionTranslationFilter` boundary |
|
||||
| [`demo7-misordered.txt`](output/demo7-misordered.txt) | An authentication filter one slot too late |
|
||||
| [`demo8-tie.txt`](output/demo8-tie.txt) | Two filters, one anchor, one order number |
|
||||
| [`demo9-double-registration.txt`](output/demo9-double-registration.txt) | A filter bean in two chains at once |
|
||||
| [`demo10-multichain.txt`](output/demo10-multichain.txt) | Three chains, three lengths |
|
||||
| [`demo11-ignoring-vs-permitall.txt`](output/demo11-ignoring-vs-permitall.txt) | A chain with zero filters |
|
||||
| [`demo12-servlet-filters.txt`](output/demo12-servlet-filters.txt) | What the servlet container sees |
|
||||
| [`tests.txt`](output/tests.txt) | The 21 assertions |
|
||||
|
||||
[← back to the module README](../README.md)
|
||||
52
filter-chain/docs/output/demo1-order-table.txt
Normal file
52
filter-chain/docs/output/demo1-order-table.txt
Normal file
@@ -0,0 +1,52 @@
|
||||
==============================================================================
|
||||
docs/output/demo1-order-table.txt
|
||||
FilterOrderRegistration, read out of spring-security-config 7.1.1 by reflection.
|
||||
GET /diag/order
|
||||
==============================================================================
|
||||
|
||||
FilterOrderRegistration, reflected out of spring-security-config.
|
||||
Slots are 100 apart, starting at 100. addFilterBefore = slot-1, addFilterAfter = slot+1.
|
||||
|
||||
100 org.springframework.security.web.session.DisableEncodeUrlFilter
|
||||
200 org.springframework.security.web.session.ForceEagerSessionCreationFilter
|
||||
300 org.springframework.security.web.access.channel.ChannelProcessingFilter <-- CLASS NOT ON CLASSPATH
|
||||
400 org.springframework.security.web.transport.HttpsRedirectFilter
|
||||
500 -- reserved, nothing registered --
|
||||
600 org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
|
||||
700 org.springframework.security.web.context.SecurityContextHolderFilter
|
||||
800 org.springframework.security.web.context.SecurityContextPersistenceFilter
|
||||
900 org.springframework.security.web.header.HeaderWriterFilter
|
||||
1000 org.springframework.web.filter.CorsFilter
|
||||
1100 org.springframework.security.web.csrf.CsrfFilter
|
||||
1200 org.springframework.security.web.authentication.logout.LogoutFilter
|
||||
1300 org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter <-- CLASS NOT ON CLASSPATH
|
||||
1400 org.springframework.security.saml2.provider.service.web.Saml2WebSsoAuthenticationRequestFilter <-- CLASS NOT ON CLASSPATH
|
||||
1500 org.springframework.security.web.authentication.ott.GenerateOneTimeTokenFilter
|
||||
1600 org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter
|
||||
1700 org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter
|
||||
1800 org.springframework.security.cas.web.CasAuthenticationFilter <-- CLASS NOT ON CLASSPATH
|
||||
1900 org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter <-- CLASS NOT ON CLASSPATH
|
||||
2000 org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter <-- CLASS NOT ON CLASSPATH
|
||||
2100 org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
|
||||
2200 org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationFilter
|
||||
2300 -- reserved, nothing registered --
|
||||
2400 org.springframework.security.web.authentication.ui.DefaultResourcesFilter
|
||||
2500 org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter
|
||||
2600 org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter
|
||||
2700 org.springframework.security.web.authentication.ui.DefaultOneTimeTokenSubmitPageGeneratingFilter
|
||||
2800 org.springframework.security.web.session.ConcurrentSessionFilter
|
||||
2900 org.springframework.security.web.authentication.www.DigestAuthenticationFilter
|
||||
3000 org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter <-- CLASS NOT ON CLASSPATH
|
||||
3100 org.springframework.security.web.authentication.www.BasicAuthenticationFilter
|
||||
3200 org.springframework.security.web.authentication.AuthenticationFilter
|
||||
3300 org.springframework.security.web.savedrequest.RequestCacheAwareFilter
|
||||
3400 org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
|
||||
3500 org.springframework.security.web.jaasapi.JaasApiIntegrationFilter
|
||||
3600 org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationFilter
|
||||
3700 org.springframework.security.web.authentication.AnonymousAuthenticationFilter
|
||||
3800 org.springframework.security.oauth2.client.web.OAuth2AuthorizationCodeGrantFilter <-- CLASS NOT ON CLASSPATH
|
||||
3900 org.springframework.security.web.session.SessionManagementFilter
|
||||
4000 org.springframework.security.web.access.ExceptionTranslationFilter
|
||||
4100 org.springframework.security.web.access.intercept.FilterSecurityInterceptor <-- CLASS NOT ON CLASSPATH
|
||||
4200 org.springframework.security.web.access.intercept.AuthorizationFilter
|
||||
4300 org.springframework.security.web.authentication.switchuser.SwitchUserFilter
|
||||
57
filter-chain/docs/output/demo10-multichain.txt
Normal file
57
filter-chain/docs/output/demo10-multichain.txt
Normal file
@@ -0,0 +1,57 @@
|
||||
==============================================================================
|
||||
docs/output/demo10-multichain.txt
|
||||
Three SecurityFilterChain beans. Evaluation stops at the first match.
|
||||
GET /diag/chains (profile: multichain)
|
||||
==============================================================================
|
||||
|
||||
FilterChainProxy holds 3 SecurityFilterChain(s).
|
||||
The FIRST chain whose matcher accepts the request wins; the rest are never consulted.
|
||||
|
||||
=== chain 1/3 matcher = Or [PathPattern [/api/**]] (12 filters)
|
||||
1/12 DisableEncodeUrlFilter order=100
|
||||
2/12 WebAsyncManagerIntegrationFilter order=600
|
||||
3/12 SecurityContextHolderFilter order=700
|
||||
4/12 HeaderWriterFilter order=900
|
||||
5/12 LogoutFilter order=1200
|
||||
6/12 BasicAuthenticationFilter order=3100
|
||||
7/12 RequestCacheAwareFilter order=3300
|
||||
8/12 SecurityContextHolderAwareRequestFilter order=3400
|
||||
9/12 AnonymousAuthenticationFilter order=3700
|
||||
10/12 SessionManagementFilter order=3900
|
||||
11/12 ExceptionTranslationFilter order=4000
|
||||
12/12 AuthorizationFilter order=4200
|
||||
|
||||
=== chain 2/3 matcher = Or [PathPattern [/diag/**], PathPattern [/public/**]] (10 filters)
|
||||
1/10 DisableEncodeUrlFilter order=100
|
||||
2/10 WebAsyncManagerIntegrationFilter order=600
|
||||
3/10 SecurityContextHolderFilter order=700
|
||||
4/10 HeaderWriterFilter order=900
|
||||
5/10 LogoutFilter order=1200
|
||||
6/10 RequestCacheAwareFilter order=3300
|
||||
7/10 SecurityContextHolderAwareRequestFilter order=3400
|
||||
8/10 AnonymousAuthenticationFilter order=3700
|
||||
9/10 ExceptionTranslationFilter order=4000
|
||||
10/10 AuthorizationFilter order=4200
|
||||
|
||||
=== chain 3/3 matcher = any request (15 filters)
|
||||
1/15 DisableEncodeUrlFilter order=100
|
||||
2/15 WebAsyncManagerIntegrationFilter order=600
|
||||
3/15 SecurityContextHolderFilter order=700
|
||||
4/15 HeaderWriterFilter order=900
|
||||
5/15 CsrfFilter order=1100
|
||||
6/15 LogoutFilter order=1200
|
||||
7/15 UsernamePasswordAuthenticationFilter order=2100
|
||||
8/15 DefaultResourcesFilter order=2400
|
||||
9/15 DefaultLoginPageGeneratingFilter order=2500
|
||||
10/15 DefaultLogoutPageGeneratingFilter order=2600
|
||||
11/15 RequestCacheAwareFilter order=3300
|
||||
12/15 SecurityContextHolderAwareRequestFilter order=3400
|
||||
13/15 AnonymousAuthenticationFilter order=3700
|
||||
14/15 ExceptionTranslationFilter order=4000
|
||||
15/15 AuthorizationFilter order=4200
|
||||
|
||||
--- which chain served what ---
|
||||
$ curl -i -u alice:password localhost:8080/api/data
|
||||
HTTP/1.1 200
|
||||
$ curl -i localhost:8080/whoami # browser chain: redirect to the login page
|
||||
HTTP/1.1 302
|
||||
38
filter-chain/docs/output/demo11-ignoring-vs-permitall.txt
Normal file
38
filter-chain/docs/output/demo11-ignoring-vs-permitall.txt
Normal file
@@ -0,0 +1,38 @@
|
||||
==============================================================================
|
||||
docs/output/demo11-ignoring-vs-permitall.txt
|
||||
WebSecurity.ignoring() produces a real chain with ZERO filters.
|
||||
GET /diag/chains (profile: ignoring)
|
||||
==============================================================================
|
||||
|
||||
FilterChainProxy holds 2 SecurityFilterChain(s).
|
||||
The FIRST chain whose matcher accepts the request wins; the rest are never consulted.
|
||||
|
||||
=== chain 1/2 matcher = PathPattern [/static/**] (0 filters)
|
||||
<no filters> - this chain does NOTHING. Requests it matches are
|
||||
unauthenticated, unauthorised, and get no security headers.
|
||||
|
||||
=== chain 2/2 matcher = any request (12 filters)
|
||||
1/12 DisableEncodeUrlFilter order=100
|
||||
2/12 WebAsyncManagerIntegrationFilter order=600
|
||||
3/12 SecurityContextHolderFilter order=700
|
||||
4/12 HeaderWriterFilter order=900
|
||||
5/12 CsrfFilter order=1100
|
||||
6/12 LogoutFilter order=1200
|
||||
7/12 BasicAuthenticationFilter order=3100
|
||||
8/12 RequestCacheAwareFilter order=3300
|
||||
9/12 SecurityContextHolderAwareRequestFilter order=3400
|
||||
10/12 AnonymousAuthenticationFilter order=3700
|
||||
11/12 ExceptionTranslationFilter order=4000
|
||||
12/12 AuthorizationFilter order=4200
|
||||
|
||||
--- response headers, ignored path vs permitAll path ---
|
||||
$ curl -sD- -o /dev/null localhost:8080/static/asset.txt
|
||||
HTTP/1.1 200
|
||||
|
||||
$ curl -sD- -o /dev/null localhost:8080/public/hello
|
||||
HTTP/1.1 200
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
Expires: 0
|
||||
17
filter-chain/docs/output/demo12-servlet-filters.txt
Normal file
17
filter-chain/docs/output/demo12-servlet-filters.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
==============================================================================
|
||||
docs/output/demo12-servlet-filters.txt
|
||||
What the servlet container has registered. FilterChainProxy is ONE entry here.
|
||||
GET /diag/servlet-filters (profile: baseline)
|
||||
==============================================================================
|
||||
|
||||
Filters registered with the servlet container:
|
||||
|
||||
Tomcat WebSocket (JSR356) Filter org.apache.tomcat.websocket.server.WsFilter urls=[/*]
|
||||
characterEncodingFilter org.springframework.boot.servlet.filter.OrderedCharacterEncodingFilter urls=[/*]
|
||||
formContentFilter org.springframework.boot.servlet.filter.OrderedFormContentFilter urls=[/*]
|
||||
requestContextFilter org.springframework.boot.servlet.filter.OrderedRequestContextFilter urls=[/*]
|
||||
springSecurityFilterChain org.springframework.boot.web.servlet.DelegatingFilterProxyRegistrationBean$1 urls=[/*]
|
||||
webMvcObservationFilter org.springframework.web.filter.ServerHttpObservationFilter urls=[/*]
|
||||
|
||||
Anything listed here runs OUTSIDE FilterChainProxy. A custom security filter that
|
||||
appears both here and in /diag/chains will run twice for every request.
|
||||
29
filter-chain/docs/output/demo2-default-chain.txt
Normal file
29
filter-chain/docs/output/demo2-default-chain.txt
Normal file
@@ -0,0 +1,29 @@
|
||||
==============================================================================
|
||||
docs/output/demo2-default-chain.txt
|
||||
The chain FilterChainProxy actually holds for the reference configuration.
|
||||
GET /diag/chains (profile: baseline)
|
||||
==============================================================================
|
||||
|
||||
FilterChainProxy holds 1 SecurityFilterChain(s).
|
||||
The FIRST chain whose matcher accepts the request wins; the rest are never consulted.
|
||||
|
||||
=== chain 1/1 matcher = any request (16 filters)
|
||||
1/16 DisableEncodeUrlFilter order=100
|
||||
2/16 WebAsyncManagerIntegrationFilter order=600
|
||||
3/16 SecurityContextHolderFilter order=700
|
||||
4/16 HeaderWriterFilter order=900
|
||||
5/16 CsrfFilter order=1100
|
||||
6/16 LogoutFilter order=1200
|
||||
7/16 UsernamePasswordAuthenticationFilter order=2100
|
||||
8/16 DefaultResourcesFilter order=2400
|
||||
9/16 DefaultLoginPageGeneratingFilter order=2500
|
||||
10/16 DefaultLogoutPageGeneratingFilter order=2600
|
||||
11/16 BasicAuthenticationFilter order=3100
|
||||
12/16 RequestCacheAwareFilter order=3300
|
||||
13/16 SecurityContextHolderAwareRequestFilter order=3400
|
||||
14/16 AnonymousAuthenticationFilter order=3700
|
||||
15/16 ExceptionTranslationFilter order=4000
|
||||
16/16 AuthorizationFilter order=4200
|
||||
|
||||
--- and the same list as Spring Security prints it at startup (DEBUG) ---
|
||||
|
||||
53
filter-chain/docs/output/demo3-trace-authenticated.txt
Normal file
53
filter-chain/docs/output/demo3-trace-authenticated.txt
Normal file
@@ -0,0 +1,53 @@
|
||||
==============================================================================
|
||||
docs/output/demo3-trace-authenticated.txt
|
||||
One authenticated GET, org.springframework.security at TRACE.
|
||||
curl -u alice:password localhost:8080/whoami
|
||||
Container and bean-factory lines removed; nothing else edited.
|
||||
==============================================================================
|
||||
|
||||
DEBUG o.a.c.authenticator.AuthenticatorBase : Security checking request GET /whoami
|
||||
DEBUG o.a.c.authenticator.AuthenticatorBase : Not subject to any constraint
|
||||
TRACE o.s.security.web.FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as 'baseline' in [class path resource [com/ankurm/chain/config/BaselineSecurityConfig.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, Logout, UsernamePasswordAuthentication, DefaultResources, DefaultLoginPageGenerating, DefaultLogoutPageGenerating, BasicAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, ExceptionTranslation, Authorization] (1/1)
|
||||
DEBUG o.s.security.web.FilterChainProxy : Securing GET /whoami
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking DisableEncodeUrlFilter (1/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking WebAsyncManagerIntegrationFilter (2/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderFilter (3/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking HeaderWriterFilter (4/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking CsrfFilter (5/16)
|
||||
TRACE o.s.s.w.c.CsrfTokenRequestAttributeHandler : Wrote a CSRF token to the following request attributes: [_csrf, org.springframework.security.web.csrf.CsrfToken]
|
||||
TRACE o.s.security.web.csrf.CsrfFilter : Did not protect against CSRF since request did not match IsNotHttpMethod [TRACE, HEAD, GET, OPTIONS]
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking LogoutFilter (6/16)
|
||||
TRACE o.s.s.w.a.logout.LogoutFilter : Did not match request to PathPattern [POST /logout]
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking UsernamePasswordAuthenticationFilter (7/16)
|
||||
TRACE o.s.s.w.a.UsernamePasswordAuthenticationFilter : Did not match request to PathPattern [POST /login]
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking DefaultResourcesFilter (8/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking DefaultLoginPageGeneratingFilter (9/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking DefaultLogoutPageGeneratingFilter (10/16)
|
||||
TRACE o.s.s.w.a.u.DefaultLogoutPageGeneratingFilter : Did not render default logout page since request did not match [PathPattern [GET /logout]]
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking BasicAuthenticationFilter (11/16)
|
||||
TRACE o.s.s.w.a.www.BasicAuthenticationFilter : Found username 'alice' in Basic Authorization header
|
||||
TRACE o.s.s.w.c.HttpSessionSecurityContextRepository : No HttpSession currently exists
|
||||
TRACE o.s.s.w.c.SupplierDeferredSecurityContext : Created SecurityContextImpl [Null authentication]
|
||||
TRACE o.s.s.w.c.SupplierDeferredSecurityContext : Created SecurityContextImpl [Null authentication]
|
||||
TRACE o.s.s.authentication.ProviderManager : Authenticating request with DaoAuthenticationProvider (1/1)
|
||||
DEBUG o.s.s.a.dao.DaoAuthenticationProvider : Authenticated user
|
||||
DEBUG o.s.s.w.a.www.BasicAuthenticationFilter : Set SecurityContextHolder to UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=alice, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=null], Granted Authorities=[ROLE_USER, FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=<timestamp>]]]
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking RequestCacheAwareFilter (12/16)
|
||||
TRACE o.s.s.w.s.HttpSessionRequestCache : matchingRequestParameterName is required for getMatchingRequest to lookup a value, but not provided
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderAwareRequestFilter (13/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking AnonymousAuthenticationFilter (14/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking ExceptionTranslationFilter (15/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking AuthorizationFilter (16/16)
|
||||
TRACE o.s.s.w.a.i.RequestMatcherDelegatingAuthorizationManager : Authorizing GET /whoami
|
||||
TRACE o.s.s.w.a.i.RequestMatcherDelegatingAuthorizationManager : Checking authorization on GET /whoami using org.springframework.security.authorization.AuthenticatedAuthorizationManager@<hash>
|
||||
TRACE o.s.s.w.a.AnonymousAuthenticationFilter : Did not set SecurityContextHolder since already authenticated UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=alice, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=null], Granted Authorities=[ROLE_USER, FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=<timestamp>]]]
|
||||
DEBUG o.s.security.web.FilterChainProxy : Secured GET /whoami
|
||||
TRACE o.s.web.servlet.DispatcherServlet : GET "/whoami", parameters={}, headers={masked} in DispatcherServlet 'dispatcherServlet'
|
||||
TRACE o.s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.ankurm.chain.web.DemoController#whoami(HttpServletRequest)
|
||||
TRACE o.s.web.method.HandlerMethod : Arguments: [SecurityContextHolderAwareRequestWrapper[ org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@<hash>]]
|
||||
DEBUG o.s.w.s.m.m.a.RequestResponseBodyMethodProcessor : Using 'text/plain', given [*/*] and supported [text/plain]
|
||||
TRACE o.s.w.s.m.m.a.RequestResponseBodyMethodProcessor : Writing ["principal=alice authenticated=true authorities=[ROLE_USER, FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=<timestamp>]] type=UsernamePasswordAuthenticationToken counting-filter-invocations=null
|
||||
"]
|
||||
TRACE o.s.s.w.header.writers.HstsHeaderWriter : Not injecting HSTS header since it did not match request to [Is Secure]
|
||||
TRACE o.s.web.servlet.DispatcherServlet : No view rendering, null ModelAndView returned.
|
||||
DEBUG o.s.web.servlet.DispatcherServlet : Completed 200 OK, headers={masked}
|
||||
144
filter-chain/docs/output/demo4-trace-csrf-403.txt
Normal file
144
filter-chain/docs/output/demo4-trace-csrf-403.txt
Normal file
@@ -0,0 +1,144 @@
|
||||
==============================================================================
|
||||
docs/output/demo4-trace-csrf-403.txt
|
||||
One POST with no CSRF token. Note WHICH filter rejects it and how far the request got.
|
||||
curl -X POST -u alice:password localhost:8080/hello
|
||||
==============================================================================
|
||||
|
||||
DEBUG o.a.c.authenticator.AuthenticatorBase : Security checking request POST /hello
|
||||
DEBUG o.a.c.authenticator.AuthenticatorBase : Not subject to any constraint
|
||||
TRACE o.s.security.web.FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as 'baseline' in [class path resource [com/ankurm/chain/config/BaselineSecurityConfig.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, Logout, UsernamePasswordAuthentication, DefaultResources, DefaultLoginPageGenerating, DefaultLogoutPageGenerating, BasicAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, ExceptionTranslation, Authorization] (1/1)
|
||||
DEBUG o.s.security.web.FilterChainProxy : Securing POST /hello
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking DisableEncodeUrlFilter (1/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking WebAsyncManagerIntegrationFilter (2/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderFilter (3/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking HeaderWriterFilter (4/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking CsrfFilter (5/16)
|
||||
TRACE o.s.s.w.c.CsrfTokenRequestAttributeHandler : Wrote a CSRF token to the following request attributes: [_csrf, org.springframework.security.web.csrf.CsrfToken]
|
||||
TRACE o.s.s.web.csrf.CsrfTokenRequestHandler : Did not find a CSRF token in the [X-CSRF-TOKEN] request header
|
||||
TRACE o.s.s.web.csrf.CsrfTokenRequestHandler : Did not find a CSRF token in the [_csrf] request parameter
|
||||
DEBUG o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/hello
|
||||
DEBUG o.s.s.w.access.AccessDeniedHandlerImpl : Responding with 403 status code
|
||||
TRACE o.s.s.w.header.writers.HstsHeaderWriter : Not injecting HSTS header since it did not match request to [Is Secure]
|
||||
DEBUG o.a.c.c.C.[Tomcat].[localhost] : Processing ErrorPage[errorCode=0, location=/error]
|
||||
TRACE o.s.security.web.FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as 'baseline' in [class path resource [com/ankurm/chain/config/BaselineSecurityConfig.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, Logout, UsernamePasswordAuthentication, DefaultResources, DefaultLoginPageGenerating, DefaultLogoutPageGenerating, BasicAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, ExceptionTranslation, Authorization] (1/1)
|
||||
DEBUG o.s.security.web.FilterChainProxy : Securing GET /error
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking DisableEncodeUrlFilter (1/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking WebAsyncManagerIntegrationFilter (2/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderFilter (3/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking HeaderWriterFilter (4/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking CsrfFilter (5/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking LogoutFilter (6/16)
|
||||
TRACE o.s.s.w.a.logout.LogoutFilter : Did not match request to PathPattern [POST /logout]
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking UsernamePasswordAuthenticationFilter (7/16)
|
||||
TRACE o.s.s.w.a.UsernamePasswordAuthenticationFilter : Did not match request to PathPattern [POST /login]
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking DefaultResourcesFilter (8/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking DefaultLoginPageGeneratingFilter (9/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking DefaultLogoutPageGeneratingFilter (10/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking BasicAuthenticationFilter (11/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking RequestCacheAwareFilter (12/16)
|
||||
TRACE o.s.s.w.s.HttpSessionRequestCache : matchingRequestParameterName is required for getMatchingRequest to lookup a value, but not provided
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderAwareRequestFilter (13/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking AnonymousAuthenticationFilter (14/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking ExceptionTranslationFilter (15/16)
|
||||
TRACE o.s.security.web.FilterChainProxy : Invoking AuthorizationFilter (16/16)
|
||||
TRACE o.s.s.w.a.i.RequestMatcherDelegatingAuthorizationManager : Authorizing GET /error
|
||||
TRACE o.s.s.w.a.i.RequestMatcherDelegatingAuthorizationManager : Checking authorization on GET /error using org.springframework.security.authorization.AuthenticatedAuthorizationManager@<hash>
|
||||
TRACE o.s.s.w.c.HttpSessionSecurityContextRepository : Did not find SecurityContext in HttpSession 6376951C7CF74BC73C543E50270726EA using the SPRING_SECURITY_CONTEXT session attribute
|
||||
TRACE o.s.s.w.c.SupplierDeferredSecurityContext : Created SecurityContextImpl [Null authentication]
|
||||
TRACE o.s.s.w.c.SupplierDeferredSecurityContext : Created SecurityContextImpl [Null authentication]
|
||||
TRACE o.s.s.w.a.AnonymousAuthenticationFilter : Set SecurityContextHolder to AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=6376951C7CF74BC73C543E50270726EA], Granted Authorities=[ROLE_ANONYMOUS]]
|
||||
TRACE o.s.s.w.a.ExceptionTranslationFilter : Sending AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=6376951C7CF74BC73C543E50270726EA], Granted Authorities=[ROLE_ANONYMOUS]] to authentication entry point since access is denied
|
||||
org.springframework.security.authorization.AuthorizationDeniedException: Access Denied
|
||||
at org.springframework.security.web.access.intercept.AuthorizationFilter.doFilter(AuthorizationFilter.java:99)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
|
||||
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:126)
|
||||
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:120)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
|
||||
at org.springframework.security.web.authentication.AnonymousAuthenticationFilter.doFilter(AnonymousAuthenticationFilter.java:100)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
|
||||
at org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter.doFilter(SecurityContextHolderAwareRequestFilter.java:181)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
|
||||
at org.springframework.security.web.savedrequest.RequestCacheAwareFilter.doFilter(RequestCacheAwareFilter.java:63)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:101)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:101)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
|
||||
at org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter.doFilter(DefaultLoginPageGeneratingFilter.java:246)
|
||||
at org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter.doFilter(DefaultLoginPageGeneratingFilter.java:232)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
|
||||
at org.springframework.security.web.authentication.ui.DefaultResourcesFilter.doFilter(DefaultResourcesFilter.java:73)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
|
||||
at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:245)
|
||||
at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:239)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
|
||||
at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:110)
|
||||
at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:96)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:101)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:101)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
|
||||
at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:82)
|
||||
at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:69)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:101)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:101)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$AroundFilterObservation$SimpleAroundFilterObservation.lambda$wrap$0(ObservationFilterChainDecorator.java:336)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227)
|
||||
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
|
||||
at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:237)
|
||||
at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:195)
|
||||
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113)
|
||||
at org.springframework.web.filter.ServletRequestPathFilter.doFilter(ServletRequestPathFilter.java:52)
|
||||
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113)
|
||||
at org.springframework.web.filter.CompositeFilter.doFilter(CompositeFilter.java:74)
|
||||
at org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration$CompositeFilterChainProxy.doFilter(WebSecurityConfiguration.java:317)
|
||||
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:355)
|
||||
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:272)
|
||||
at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:101)
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:101)
|
||||
at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:406)
|
||||
at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:71)
|
||||
at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:1307)
|
||||
at java.base/java.lang.Thread.run(Thread.java:1474)
|
||||
DEBUG o.s.s.w.s.HttpSessionRequestCache : Saved request http://localhost:8080/error?continue to session
|
||||
DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint : Trying to match using Or [RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest], And [Not [MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@<hash>, matchingMediaTypes=[text/html], useEquals=false, ignoredMediaTypes=[]]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@<hash>, matchingMediaTypes=[application/atom+xml, application/x-www-form-urlencoded, application/json, application/octet-stream, application/xml, multipart/form-data, text/xml], useEquals=false, ignoredMediaTypes=[*/*]]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@<hash>, matchingMediaTypes=[*/*], useEquals=true, ignoredMediaTypes=[]]]
|
||||
DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint : Match found! Executing org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint@<hash>
|
||||
DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint : Trying to match using RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]
|
||||
DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint : No match found. Using default entry point org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint@<hash>
|
||||
DEBUG o.a.c.c.C.[.[.[/].[dispatcherServlet] : Disabling the response for further output
|
||||
30
filter-chain/docs/output/demo5-custom-placement.txt
Normal file
30
filter-chain/docs/output/demo5-custom-placement.txt
Normal file
@@ -0,0 +1,30 @@
|
||||
==============================================================================
|
||||
docs/output/demo5-custom-placement.txt
|
||||
Four custom filters at four anchors.
|
||||
GET /diag/chains (profile: custom)
|
||||
==============================================================================
|
||||
|
||||
FilterChainProxy holds 1 SecurityFilterChain(s).
|
||||
The FIRST chain whose matcher accepts the request wins; the rest are never consulted.
|
||||
|
||||
=== chain 1/1 matcher = any request (20 filters)
|
||||
1/20 DisableEncodeUrlFilter order=100
|
||||
2/20 WebAsyncManagerIntegrationFilter order=600
|
||||
3/20 SecurityContextHolderFilter order=700
|
||||
4/20 RequestIdFilter order=? (not in the registration table)
|
||||
5/20 HeaderWriterFilter order=900
|
||||
6/20 CsrfFilter order=1100
|
||||
7/20 LogoutFilter order=1200
|
||||
8/20 ApiKeyAuthenticationFilter order=? (not in the registration table)
|
||||
9/20 UsernamePasswordAuthenticationFilter order=2100
|
||||
10/20 DefaultResourcesFilter order=2400
|
||||
11/20 DefaultLoginPageGeneratingFilter order=2500
|
||||
12/20 DefaultLogoutPageGeneratingFilter order=2600
|
||||
13/20 BasicAuthenticationFilter order=3100
|
||||
14/20 RequestCacheAwareFilter order=3300
|
||||
15/20 SecurityContextHolderAwareRequestFilter order=3400
|
||||
16/20 AnonymousAuthenticationFilter order=3700
|
||||
17/20 TenantFilter order=? (not in the registration table)
|
||||
18/20 ExceptionTranslationFilter order=4000
|
||||
19/20 TenantFilter order=? (not in the registration table)
|
||||
20/20 AuthorizationFilter order=4200
|
||||
34
filter-chain/docs/output/demo6-exception-translation.txt
Normal file
34
filter-chain/docs/output/demo6-exception-translation.txt
Normal file
@@ -0,0 +1,34 @@
|
||||
==============================================================================
|
||||
docs/output/demo6-exception-translation.txt
|
||||
Two identical TenantFilters, 300 apart, straddling ExceptionTranslationFilter (4000).
|
||||
Both throw AccessDeniedException. Neither of them produces a 403 - for two different reasons.
|
||||
==============================================================================
|
||||
|
||||
|
||||
$ curl -i -u alice:password localhost:8080/tenant/doc # filter at order 3701
|
||||
HTTP/1.1 500
|
||||
|
||||
$ curl -i -u alice:password localhost:8080/tenant/translated # filter at order 4001
|
||||
HTTP/1.1 200
|
||||
|
||||
$ curl -i -u alice:password -H "X-Tenant-Id: acme" localhost:8080/tenant/doc
|
||||
HTTP/1.1 200
|
||||
|
||||
$ curl -s -H "X-Api-Key: let-me-in" localhost:8080/whoami # api key filter at 1201
|
||||
principal=api-client authenticated=true authorities=[ROLE_API] type=UsernamePasswordAuthenticationToken counting-filter-invocations=null
|
||||
|
||||
$ curl -s -D- -o /dev/null localhost:8080/public/hello | grep X-Request-Id # filter at 701
|
||||
X-Request-Id: 74ee45fe-9eda-3820-8110-63a6116aa155
|
||||
|
||||
--- the same four filters, run with -DUNIQUE_ONCE_KEY=true ---
|
||||
Each TenantFilter now has its own OncePerRequestFilter key, so both actually execute.
|
||||
|
||||
$ curl -i -u alice:password localhost:8080/tenant/doc # filter at order 3701
|
||||
HTTP/1.1 500
|
||||
|
||||
$ curl -i -u alice:password localhost:8080/tenant/translated # filter at order 4001
|
||||
HTTP/1.1 403
|
||||
|
||||
Same code, same exception, 300 order slots apart:
|
||||
order 3701 - below ExceptionTranslationFilter (4000) - the throw escapes untranslated
|
||||
order 4001 - above it - the throw becomes a 403
|
||||
27
filter-chain/docs/output/demo7-misordered.txt
Normal file
27
filter-chain/docs/output/demo7-misordered.txt
Normal file
@@ -0,0 +1,27 @@
|
||||
==============================================================================
|
||||
docs/output/demo7-misordered.txt
|
||||
The SAME ApiKeyAuthenticationFilter, moved from order 1201 to 4201.
|
||||
The key is valid. The filter runs. The request is still rejected.
|
||||
==============================================================================
|
||||
|
||||
|
||||
--- chain ---
|
||||
FilterChainProxy holds 1 SecurityFilterChain(s).
|
||||
The FIRST chain whose matcher accepts the request wins; the rest are never consulted.
|
||||
|
||||
=== chain 1/1 matcher = any request (12 filters)
|
||||
1/12 DisableEncodeUrlFilter order=100
|
||||
2/12 WebAsyncManagerIntegrationFilter order=600
|
||||
3/12 SecurityContextHolderFilter order=700
|
||||
4/12 HeaderWriterFilter order=900
|
||||
5/12 LogoutFilter order=1200
|
||||
6/12 BasicAuthenticationFilter order=3100
|
||||
7/12 RequestCacheAwareFilter order=3300
|
||||
8/12 SecurityContextHolderAwareRequestFilter order=3400
|
||||
9/12 AnonymousAuthenticationFilter order=3700
|
||||
10/12 ExceptionTranslationFilter order=4000
|
||||
11/12 AuthorizationFilter order=4200
|
||||
12/12 ApiKeyAuthenticationFilter order=? (not in the registration table)
|
||||
|
||||
$ curl -i -H "X-Api-Key: let-me-in" localhost:8080/whoami
|
||||
HTTP/1.1 401
|
||||
23
filter-chain/docs/output/demo8-tie.txt
Normal file
23
filter-chain/docs/output/demo8-tie.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
==============================================================================
|
||||
docs/output/demo8-tie.txt
|
||||
addFilterBefore(x, CsrfFilter.class) twice. Both filters get order 1099.
|
||||
==============================================================================
|
||||
|
||||
|
||||
--- A registered first ---
|
||||
executed order: [A, B]
|
||||
|
||||
--- chain positions ---
|
||||
5/12 MarkerFilterA order=? (not in the registration table)
|
||||
6/12 MarkerFilterB order=? (not in the registration table)
|
||||
|
||||
--- B registered first (-DTIE_REVERSED=true), nothing else changed ---
|
||||
executed order: [B, A]
|
||||
|
||||
--- chain positions ---
|
||||
5/12 MarkerFilterB order=? (not in the registration table)
|
||||
6/12 MarkerFilterA order=? (not in the registration table)
|
||||
|
||||
The order number is identical in both runs. The executed order follows the order of
|
||||
the addFilterBefore calls, because List.sort is stable - not because Spring Security
|
||||
promises anything about ties.
|
||||
20
filter-chain/docs/output/demo9-double-registration.txt
Normal file
20
filter-chain/docs/output/demo9-double-registration.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
==============================================================================
|
||||
docs/output/demo9-double-registration.txt
|
||||
A CountingFilter @Bean added to the security chain. Boot ALSO registers every Filter bean
|
||||
with the servlet container, so it is in two chains at once.
|
||||
==============================================================================
|
||||
|
||||
|
||||
--- profile: doublereg ---
|
||||
$ curl -sD- -o /dev/null localhost:8080/whoami | grep X-Counting
|
||||
X-Counting-Filter-Invocations: 2
|
||||
|
||||
$ curl -s localhost:8080/diag/servlet-filters | grep -i counting
|
||||
countingFilter com.ankurm.chain.filter.CountingFilter urls=[/*]
|
||||
|
||||
--- profile: doublereg,fixed (FilterRegistrationBean.setEnabled(false)) ---
|
||||
$ curl -sD- -o /dev/null localhost:8080/whoami | grep X-Counting
|
||||
X-Counting-Filter-Invocations: 1
|
||||
|
||||
$ curl -s localhost:8080/diag/servlet-filters | grep -i counting
|
||||
(not registered with the container)
|
||||
114
filter-chain/docs/output/tests.txt
Normal file
114
filter-chain/docs/output/tests.txt
Normal file
@@ -0,0 +1,114 @@
|
||||
[INFO] T E S T S
|
||||
[INFO] -------------------------------------------------------
|
||||
[INFO] Running com.ankurm.chain.FilterChainContractTest
|
||||
[INFO] Running two filters added before the same anchor
|
||||
01:50:14.438 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Tie]: Tie does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
01:50:14.614 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Tie
|
||||
01:50:14.679 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Tie]: Tie does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
01:50:14.680 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest]: FilterChainContractTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
01:50:14.684 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Tie
|
||||
INFO c.a.chain.FilterChainContractTest$Tie : Starting FilterChainContractTest.Tie using Java 25.0.4.1 with PID 8588 (started by root in /tmp/ssd/filter-chain)
|
||||
INFO c.a.chain.FilterChainContractTest$Tie : The following 1 profile is active: "tie"
|
||||
INFO o.s.s.c.a.a.c.InitializeUserDetailsBeanManagerConfigurer$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name users
|
||||
INFO o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
|
||||
INFO o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
|
||||
INFO o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'
|
||||
INFO o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 33 ms
|
||||
INFO c.a.chain.FilterChainContractTest$Tie : Started FilterChainContractTest.Tie in 2.615 seconds (process running for 3.902)
|
||||
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
|
||||
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
|
||||
WARNING: A Java agent has been loaded dynamically (/root/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.11/byte-buddy-agent-1.18.11.jar)
|
||||
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
|
||||
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
|
||||
WARNING: Dynamic loading of agents will be disallowed by default in a future release
|
||||
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.951 s -- in two filters added before the same anchor
|
||||
[INFO] Running the reference configuration
|
||||
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Baseline]: Baseline does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Baseline
|
||||
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Baseline]: Baseline does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest]: FilterChainContractTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Baseline
|
||||
INFO c.a.c.FilterChainContractTest$Baseline : Starting FilterChainContractTest.Baseline using Java 25.0.4.1 with PID 8588 (started by root in /tmp/ssd/filter-chain)
|
||||
INFO c.a.c.FilterChainContractTest$Baseline : The following 1 profile is active: "baseline"
|
||||
INFO o.s.s.c.a.a.c.InitializeUserDetailsBeanManagerConfigurer$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name users
|
||||
INFO o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
|
||||
INFO o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
|
||||
INFO o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'
|
||||
INFO o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 6 ms
|
||||
INFO c.a.c.FilterChainContractTest$Baseline : Started FilterChainContractTest.Baseline in 0.698 seconds (process running for 5.482)
|
||||
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.761 s -- in the reference configuration
|
||||
[INFO] Running WebSecurity.ignoring()
|
||||
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Ignoring]: Ignoring does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Ignoring
|
||||
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Ignoring]: Ignoring does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest]: FilterChainContractTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Ignoring
|
||||
INFO c.a.c.FilterChainContractTest$Ignoring : Starting FilterChainContractTest.Ignoring using Java 25.0.4.1 with PID 8588 (started by root in /tmp/ssd/filter-chain)
|
||||
INFO c.a.c.FilterChainContractTest$Ignoring : The following 1 profile is active: "ignoring"
|
||||
INFO o.s.s.c.a.a.c.InitializeUserDetailsBeanManagerConfigurer$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name users
|
||||
WARN o.s.s.c.a.web.builders.WebSecurity : You are asking Spring Security to ignore PathPattern [/static/**]. This is not recommended -- please use permitAll via HttpSecurity#authorizeHttpRequests instead.
|
||||
INFO o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
|
||||
INFO o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
|
||||
INFO o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'
|
||||
INFO o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 8 ms
|
||||
INFO c.a.c.FilterChainContractTest$Ignoring : Started FilterChainContractTest.Ignoring in 0.616 seconds (process running for 6.187)
|
||||
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.771 s -- in WebSecurity.ignoring()
|
||||
[INFO] Running custom filters
|
||||
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Custom]: Custom does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Custom
|
||||
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Custom]: Custom does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest]: FilterChainContractTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Custom
|
||||
INFO c.a.c.FilterChainContractTest$Custom : Starting FilterChainContractTest.Custom using Java 25.0.4.1 with PID 8588 (started by root in /tmp/ssd/filter-chain)
|
||||
INFO c.a.c.FilterChainContractTest$Custom : The following 1 profile is active: "custom"
|
||||
INFO o.s.s.c.a.a.c.InitializeUserDetailsBeanManagerConfigurer$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name users
|
||||
INFO o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
|
||||
INFO o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
|
||||
INFO o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'
|
||||
INFO o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 4 ms
|
||||
INFO c.a.c.FilterChainContractTest$Custom : Started FilterChainContractTest.Custom in 0.477 seconds (process running for 6.82)
|
||||
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.578 s -- in custom filters
|
||||
[INFO] Running an authentication filter after AuthorizationFilter
|
||||
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Misordered]: Misordered does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Misordered
|
||||
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Misordered]: Misordered does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest]: FilterChainContractTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Misordered
|
||||
INFO c.a.c.FilterChainContractTest$Misordered : Starting FilterChainContractTest.Misordered using Java 25.0.4.1 with PID 8588 (started by root in /tmp/ssd/filter-chain)
|
||||
INFO c.a.c.FilterChainContractTest$Misordered : The following 1 profile is active: "misordered"
|
||||
INFO o.s.s.c.a.a.c.InitializeUserDetailsBeanManagerConfigurer$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name users
|
||||
INFO o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
|
||||
INFO o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
|
||||
INFO o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'
|
||||
INFO o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 4 ms
|
||||
INFO c.a.c.FilterChainContractTest$Misordered : Started FilterChainContractTest.Misordered in 0.352 seconds (process running for 7.247)
|
||||
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.389 s -- in an authentication filter after AuthorizationFilter
|
||||
[INFO] Running three SecurityFilterChain beans
|
||||
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$MultiChain]: MultiChain does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$MultiChain
|
||||
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$MultiChain]: MultiChain does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest]: FilterChainContractTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
|
||||
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$MultiChain
|
||||
INFO c.a.c.FilterChainContractTest$MultiChain : Starting FilterChainContractTest.MultiChain using Java 25.0.4.1 with PID 8588 (started by root in /tmp/ssd/filter-chain)
|
||||
INFO c.a.c.FilterChainContractTest$MultiChain : The following 1 profile is active: "multichain"
|
||||
INFO o.s.s.c.a.a.c.InitializeUserDetailsBeanManagerConfigurer$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name users
|
||||
INFO o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
|
||||
INFO o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
|
||||
INFO o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'
|
||||
INFO o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 18 ms
|
||||
INFO c.a.c.FilterChainContractTest$MultiChain : Started FilterChainContractTest.MultiChain in 0.398 seconds (process running for 7.681)
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.426 s -- in three SecurityFilterChain beans
|
||||
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 6.924 s -- in com.ankurm.chain.FilterChainContractTest
|
||||
[INFO] Running com.ankurm.chain.FilterOrderTableTest
|
||||
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.052 s -- in com.ankurm.chain.FilterOrderTableTest
|
||||
[INFO]
|
||||
[INFO] Results:
|
||||
[INFO]
|
||||
[INFO] Tests run: 21, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO]
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] BUILD SUCCESS
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] Total time: 9.196 s
|
||||
[INFO] Finished at: <timestamp>
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
67
filter-chain/pom.xml
Normal file
67
filter-chain/pom.xml
Normal file
@@ -0,0 +1,67 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<!-- Inheriting spring-boot-starter-parent (rather than importing the BOM) so that this
|
||||
module gets Boot's own compiler settings, including <parameters>true</parameters>.
|
||||
Every version below is managed by the parent; nothing here is pinned by hand. See
|
||||
docs/01-the-two-proxies.md for why this module is a real servlet app while the other
|
||||
modules in this repository are plain main() classes. -->
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>filter-chain-verify</artifactId>
|
||||
<version>1.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<!-- Present ONLY so docs/06-observability-changes-the-trace.md can show what an
|
||||
ObservationRegistry bean does to FilterChainProxy's decorator. Remove it and the
|
||||
TRACE output in docs/output/demo3-trace-annotated.txt changes shape. -->
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
230
filter-chain/scripts/run-all.sh
Executable file
230
filter-chain/scripts/run-all.sh
Executable file
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file under docs/output/ from a real run. Nothing in docs/output/ is
|
||||
# hand-written; if a number in the article disagrees with a file here, the file is right.
|
||||
#
|
||||
# ./scripts/run-all.sh
|
||||
#
|
||||
# Takes a few minutes: the application is restarted once per scenario because the scenarios
|
||||
# are Spring profiles, and profiles are fixed at context startup.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
OUT=docs/output
|
||||
mkdir -p "$OUT"
|
||||
|
||||
hdr() { printf '%s\n%s\n%s\n\n' "$(printf '=%.0s' $(seq 1 78))" "$1" "$(printf '=%.0s' $(seq 1 78))"; }
|
||||
|
||||
# Strip run-to-run noise so the committed files diff cleanly: timestamps, ports, session ids,
|
||||
# object hashes, and the JAVA_TOOL_OPTIONS banner this sandbox injects.
|
||||
scrub() {
|
||||
sed -E \
|
||||
-e 's/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z?/<timestamp>/g' \
|
||||
-e 's/@[0-9a-f]{6,}/@<hash>/g' \
|
||||
-e 's/(JSESSIONID=)[0-9A-F]+/\1<session>/g' \
|
||||
-e 's/remote=\/127\.0\.0\.1:[0-9]+/remote=\/127.0.0.1:<port>/g' \
|
||||
-e '/Picked up JAVA_TOOL_OPTIONS/d'
|
||||
}
|
||||
|
||||
trace_request() { # trace_request <curl args...>
|
||||
local mark
|
||||
mark=$(wc -l < /tmp/trace.log)
|
||||
curl -s -o /dev/null "$@" > /dev/null 2>&1 || true
|
||||
sleep 1
|
||||
sed -n "$((mark + 1)),\$p" /tmp/trace.log \
|
||||
| grep -vE 'tomcat|catalina|DefaultListableBeanFactory|LimitLatch|NioEndpoint|SocketWrapperBase|Parameters' \
|
||||
| scrub
|
||||
}
|
||||
|
||||
########################################################################################
|
||||
# 1 + 2 + 12: the reference chain, the order table, and the container's own filters
|
||||
########################################################################################
|
||||
./scripts/run.sh baseline > /dev/null
|
||||
{
|
||||
hdr "docs/output/demo1-order-table.txt
|
||||
FilterOrderRegistration, read out of spring-security-config 7.1.1 by reflection.
|
||||
GET /diag/order"
|
||||
curl -s localhost:8080/diag/order
|
||||
} | scrub > "$OUT/demo1-order-table.txt"
|
||||
|
||||
{
|
||||
hdr "docs/output/demo2-default-chain.txt
|
||||
The chain FilterChainProxy actually holds for the reference configuration.
|
||||
GET /diag/chains (profile: baseline)"
|
||||
curl -s localhost:8080/diag/chains
|
||||
printf '\n--- and the same list as Spring Security prints it at startup (DEBUG) ---\n\n'
|
||||
grep -m1 "Will secure" "${LOG:-/tmp/filter-chain-app.log}" || true
|
||||
} | scrub > "$OUT/demo2-default-chain.txt"
|
||||
|
||||
{
|
||||
hdr "docs/output/demo12-servlet-filters.txt
|
||||
What the servlet container has registered. FilterChainProxy is ONE entry here.
|
||||
GET /diag/servlet-filters (profile: baseline)"
|
||||
curl -s localhost:8080/diag/servlet-filters
|
||||
} | scrub > "$OUT/demo12-servlet-filters.txt"
|
||||
|
||||
########################################################################################
|
||||
# 3 + 4: TRACE for a request that succeeds and a request that is rejected
|
||||
########################################################################################
|
||||
TRACE=1 LOG=/tmp/trace.log ./scripts/run.sh baseline > /dev/null
|
||||
{
|
||||
hdr "docs/output/demo3-trace-authenticated.txt
|
||||
One authenticated GET, org.springframework.security at TRACE.
|
||||
curl -u alice:password localhost:8080/whoami
|
||||
Container and bean-factory lines removed; nothing else edited."
|
||||
trace_request -u alice:password localhost:8080/whoami
|
||||
} > "$OUT/demo3-trace-authenticated.txt"
|
||||
|
||||
{
|
||||
hdr "docs/output/demo4-trace-csrf-403.txt
|
||||
One POST with no CSRF token. Note WHICH filter rejects it and how far the request got.
|
||||
curl -X POST -u alice:password localhost:8080/hello"
|
||||
trace_request -X POST -u alice:password localhost:8080/hello
|
||||
} > "$OUT/demo4-trace-csrf-403.txt"
|
||||
|
||||
########################################################################################
|
||||
# 5 + 6: custom filters - where they land, and the ExceptionTranslationFilter boundary
|
||||
########################################################################################
|
||||
./scripts/run.sh custom > /dev/null
|
||||
{
|
||||
hdr "docs/output/demo5-custom-placement.txt
|
||||
Four custom filters at four anchors.
|
||||
GET /diag/chains (profile: custom)"
|
||||
curl -s localhost:8080/diag/chains
|
||||
} | scrub > "$OUT/demo5-custom-placement.txt"
|
||||
|
||||
{
|
||||
hdr "docs/output/demo6-exception-translation.txt
|
||||
Two identical TenantFilters, 300 apart, straddling ExceptionTranslationFilter (4000).
|
||||
Both throw AccessDeniedException. Neither of them produces a 403 - for two different reasons."
|
||||
printf '\n$ curl -i -u alice:password localhost:8080/tenant/doc # filter at order 3701\n'
|
||||
curl -s -i -u alice:password localhost:8080/tenant/doc | head -1
|
||||
printf '\n$ curl -i -u alice:password localhost:8080/tenant/translated # filter at order 4001\n'
|
||||
curl -s -i -u alice:password localhost:8080/tenant/translated | head -1
|
||||
printf '\n$ curl -i -u alice:password -H "X-Tenant-Id: acme" localhost:8080/tenant/doc\n'
|
||||
curl -s -i -u alice:password -H "X-Tenant-Id: acme" localhost:8080/tenant/doc | head -1
|
||||
printf '\n$ curl -s -H "X-Api-Key: let-me-in" localhost:8080/whoami # api key filter at 1201\n'
|
||||
curl -s -H "X-Api-Key: let-me-in" localhost:8080/whoami
|
||||
printf '\n$ curl -s -D- -o /dev/null localhost:8080/public/hello | grep X-Request-Id # filter at 701\n'
|
||||
curl -s -D- -o /dev/null localhost:8080/public/hello | grep -i 'x-request-id' || true
|
||||
} | scrub > "$OUT/demo6-exception-translation.txt"
|
||||
|
||||
# The filter at 4001 above answered 200, not 403. It is in the chain - demo5 shows it - but it
|
||||
# never ran: both TenantFilter instances are the same OncePerRequestFilter subclass, so they
|
||||
# share the "<class>.FILTERED" request attribute and the second one skips itself. Give each
|
||||
# instance its own key and only then does the placement question become visible.
|
||||
JVM_ARGS="-DUNIQUE_ONCE_KEY=true" ./scripts/run.sh custom > /dev/null
|
||||
{
|
||||
printf '\n--- the same four filters, run with -DUNIQUE_ONCE_KEY=true ---\n'
|
||||
printf 'Each TenantFilter now has its own OncePerRequestFilter key, so both actually execute.\n'
|
||||
printf '\n$ curl -i -u alice:password localhost:8080/tenant/doc # filter at order 3701\n'
|
||||
curl -s -i -u alice:password localhost:8080/tenant/doc | head -1
|
||||
printf '\n$ curl -i -u alice:password localhost:8080/tenant/translated # filter at order 4001\n'
|
||||
curl -s -i -u alice:password localhost:8080/tenant/translated | head -1
|
||||
printf '\nSame code, same exception, 300 order slots apart:\n'
|
||||
printf ' order 3701 - below ExceptionTranslationFilter (4000) - the throw escapes untranslated\n'
|
||||
printf ' order 4001 - above it - the throw becomes a 403\n'
|
||||
} | scrub >> "$OUT/demo6-exception-translation.txt"
|
||||
|
||||
########################################################################################
|
||||
# 7: the authentication filter on the wrong side of AuthorizationFilter
|
||||
########################################################################################
|
||||
./scripts/run.sh misordered > /dev/null
|
||||
{
|
||||
hdr "docs/output/demo7-misordered.txt
|
||||
The SAME ApiKeyAuthenticationFilter, moved from order 1201 to 4201.
|
||||
The key is valid. The filter runs. The request is still rejected."
|
||||
printf '\n--- chain ---\n'
|
||||
curl -s localhost:8080/diag/chains
|
||||
printf '\n$ curl -i -H "X-Api-Key: let-me-in" localhost:8080/whoami\n'
|
||||
curl -s -i -H "X-Api-Key: let-me-in" localhost:8080/whoami | head -1
|
||||
} | scrub > "$OUT/demo7-misordered.txt"
|
||||
|
||||
########################################################################################
|
||||
# 8: two filters, one anchor, one order number
|
||||
########################################################################################
|
||||
./scripts/run.sh tie > /dev/null
|
||||
{
|
||||
hdr "docs/output/demo8-tie.txt
|
||||
addFilterBefore(x, CsrfFilter.class) twice. Both filters get order 1099."
|
||||
printf '\n--- A registered first ---\n'
|
||||
curl -s localhost:8080/markers
|
||||
printf '\n--- chain positions ---\n'
|
||||
curl -s localhost:8080/diag/chains | grep -E 'MarkerFilter|CsrfFilter'
|
||||
} | scrub > "$OUT/demo8-tie.txt"
|
||||
|
||||
JVM_ARGS="-DTIE_REVERSED=true" ./scripts/run.sh tie > /dev/null
|
||||
{
|
||||
printf '\n--- B registered first (-DTIE_REVERSED=true), nothing else changed ---\n'
|
||||
curl -s localhost:8080/markers
|
||||
printf '\n--- chain positions ---\n'
|
||||
curl -s localhost:8080/diag/chains | grep -E 'MarkerFilter|CsrfFilter'
|
||||
printf '\nThe order number is identical in both runs. The executed order follows the order of\n'
|
||||
printf 'the addFilterBefore calls, because List.sort is stable - not because Spring Security\n'
|
||||
printf 'promises anything about ties.\n'
|
||||
} | scrub >> "$OUT/demo8-tie.txt"
|
||||
|
||||
########################################################################################
|
||||
# 9: one filter bean, two registrations
|
||||
########################################################################################
|
||||
./scripts/run.sh doublereg > /dev/null
|
||||
{
|
||||
hdr "docs/output/demo9-double-registration.txt
|
||||
A CountingFilter @Bean added to the security chain. Boot ALSO registers every Filter bean
|
||||
with the servlet container, so it is in two chains at once."
|
||||
printf '\n--- profile: doublereg ---\n'
|
||||
printf '$ curl -sD- -o /dev/null localhost:8080/whoami | grep X-Counting\n'
|
||||
curl -s -D- -o /dev/null localhost:8080/whoami | grep -i 'x-counting' || true
|
||||
printf '\n$ curl -s localhost:8080/diag/servlet-filters | grep -i counting\n'
|
||||
curl -s localhost:8080/diag/servlet-filters | grep -i counting || true
|
||||
} | scrub > "$OUT/demo9-double-registration.txt"
|
||||
|
||||
./scripts/run.sh doublereg,fixed > /dev/null
|
||||
{
|
||||
printf '\n--- profile: doublereg,fixed (FilterRegistrationBean.setEnabled(false)) ---\n'
|
||||
printf '$ curl -sD- -o /dev/null localhost:8080/whoami | grep X-Counting\n'
|
||||
curl -s -D- -o /dev/null localhost:8080/whoami | grep -i 'x-counting' || true
|
||||
printf '\n$ curl -s localhost:8080/diag/servlet-filters | grep -i counting\n'
|
||||
curl -s localhost:8080/diag/servlet-filters | grep -i counting || printf ' (not registered with the container)\n'
|
||||
} | scrub >> "$OUT/demo9-double-registration.txt"
|
||||
|
||||
########################################################################################
|
||||
# 10: three chains
|
||||
########################################################################################
|
||||
./scripts/run.sh multichain > /dev/null
|
||||
{
|
||||
hdr "docs/output/demo10-multichain.txt
|
||||
Three SecurityFilterChain beans. Evaluation stops at the first match.
|
||||
GET /diag/chains (profile: multichain)"
|
||||
curl -s localhost:8080/diag/chains
|
||||
printf '\n--- which chain served what ---\n'
|
||||
printf '$ curl -i -u alice:password localhost:8080/api/data\n'
|
||||
curl -s -i -u alice:password localhost:8080/api/data | head -1
|
||||
printf '$ curl -i localhost:8080/whoami # browser chain: redirect to the login page\n'
|
||||
curl -s -i localhost:8080/whoami | head -1
|
||||
} | scrub > "$OUT/demo10-multichain.txt"
|
||||
|
||||
########################################################################################
|
||||
# 11: permitAll vs ignoring
|
||||
########################################################################################
|
||||
./scripts/run.sh ignoring > /dev/null
|
||||
{
|
||||
hdr "docs/output/demo11-ignoring-vs-permitall.txt
|
||||
WebSecurity.ignoring() produces a real chain with ZERO filters.
|
||||
GET /diag/chains (profile: ignoring)"
|
||||
curl -s localhost:8080/diag/chains
|
||||
printf '\n--- response headers, ignored path vs permitAll path ---\n'
|
||||
printf '$ curl -sD- -o /dev/null localhost:8080/static/asset.txt\n'
|
||||
curl -s -D- -o /dev/null localhost:8080/static/asset.txt | grep -iE '^(HTTP|X-Content|X-XSS|Cache-Control|Pragma|Expires)' || true
|
||||
printf '\n$ curl -sD- -o /dev/null localhost:8080/public/hello\n'
|
||||
curl -s -D- -o /dev/null localhost:8080/public/hello | grep -iE '^(HTTP|X-Content|X-XSS|Cache-Control|Pragma|Expires)' || true
|
||||
} | scrub > "$OUT/demo11-ignoring-vs-permitall.txt"
|
||||
|
||||
./scripts/stop.sh
|
||||
|
||||
########################################################################################
|
||||
# The assertions
|
||||
########################################################################################
|
||||
mvn -B test 2>&1 | sed -n '/T E S T S/,$p' | scrub > "$OUT/tests.txt" || true
|
||||
|
||||
echo
|
||||
echo "regenerated:"
|
||||
ls -1 "$OUT"
|
||||
38
filter-chain/scripts/run.sh
Executable file
38
filter-chain/scripts/run.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the demo application with a given set of profiles and wait until it answers.
|
||||
#
|
||||
# ./scripts/run.sh baseline
|
||||
# ./scripts/run.sh custom
|
||||
# ./scripts/run.sh doublereg,fixed
|
||||
# TRACE=1 ./scripts/run.sh baseline # org.springframework.security at TRACE
|
||||
#
|
||||
# Kills any previous instance first - by main class, never by a 'spring-boot' pattern, which
|
||||
# would also match the shell running this script.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
PROFILES="${1:-baseline}"
|
||||
LOG="${LOG:-/tmp/filter-chain-app.log}"
|
||||
|
||||
./scripts/stop.sh
|
||||
|
||||
EXTRA=""
|
||||
if [ "${TRACE:-0}" = "1" ]; then
|
||||
EXTRA="-Dlogging.level.org.springframework.security=TRACE"
|
||||
fi
|
||||
|
||||
setsid nohup mvn -B org.springframework.boot:spring-boot-maven-plugin:run \
|
||||
-Dspring-boot.run.profiles="$PROFILES" \
|
||||
-Dspring-boot.run.jvmArguments="${JVM_ARGS:-} $EXTRA" \
|
||||
> "$LOG" 2>&1 < /dev/null &
|
||||
|
||||
for _ in $(seq 1 90); do
|
||||
if curl -sf -o /dev/null http://localhost:8080/diag/chains 2>/dev/null; then
|
||||
echo "started with profiles: $PROFILES (log: $LOG)"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "application did not become ready; see $LOG" >&2
|
||||
tail -40 "$LOG" >&2
|
||||
exit 1
|
||||
11
filter-chain/scripts/stop.sh
Executable file
11
filter-chain/scripts/stop.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stop the demo application.
|
||||
#
|
||||
# Note the bracket in the grep pattern: it stops the pattern matching this script's own
|
||||
# process. And note that we match the MAIN CLASS, not 'spring-boot' - matching 'spring-boot'
|
||||
# also matches the shell command line that started it, which kills your own shell.
|
||||
set -eu
|
||||
for pid in $(ps -eo pid,cmd | grep '[F]ilterChainDemoApplication' | awk '{print $1}'); do
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
done
|
||||
sleep 1
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ankurm.chain;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* Companion application for
|
||||
* <a href="https://ankurm.com/spring-security-filter-chain-explained/">The Spring Security
|
||||
* Filter Chain Explained</a>.
|
||||
*
|
||||
* <p>Every scenario in the article is a Spring profile on this one application. Nothing here
|
||||
* is a toy re-implementation: the chains you see printed by {@code /diag/chains} are the
|
||||
* chains {@code FilterChainProxy} actually holds, read back out of the live bean.
|
||||
*
|
||||
* <p>Start it with {@code ./scripts/run.sh <profile>} and regenerate every captured file
|
||||
* under {@code docs/output/} with {@code ./scripts/run-all.sh}.
|
||||
*
|
||||
* @see com.ankurm.chain.web.DiagnosticsController
|
||||
* @see <a href="../../../../../../docs/README.md">Documentation index</a>
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class FilterChainDemoApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(FilterChainDemoApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.ankurm.chain.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* The reference configuration — the one whose chain is reproduced in the article's table.
|
||||
* Active by default ({@code spring.profiles.default=baseline}).
|
||||
*
|
||||
* <p>It turns on exactly the four features the reference documentation's example turns on, so
|
||||
* the chain it produces is directly comparable: CSRF, HTTP Basic, form login, and request
|
||||
* authorization. Everything <em>else</em> in the resulting chain is there because
|
||||
* {@code HttpSecurity} puts it there whether you ask or not, which is the interesting part.
|
||||
*
|
||||
* <p>Declaration order in this method is irrelevant. {@code HttpSecurity.performBuild()} sorts
|
||||
* the accumulated filters with {@code OrderComparator} before handing them to
|
||||
* {@code DefaultSecurityFilterChain}, so moving {@code .csrf(..)} below {@code .httpBasic(..)}
|
||||
* changes nothing. Pinned by
|
||||
* {@code FilterChainContractTest.declarationOrderDoesNotAffectChainOrder}.
|
||||
*
|
||||
* @see <a href="../../../../../../../docs/02-the-default-chain.md">docs/02-the-default-chain.md</a>
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("baseline")
|
||||
public class BaselineSecurityConfig {
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain baseline(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(Customizer.withDefaults())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.formLogin(Customizer.withDefaults())
|
||||
.authorizeHttpRequests((authorize) -> authorize
|
||||
.requestMatchers("/public/**", "/diag/**").permitAll()
|
||||
.anyRequest().authenticated());
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ankurm.chain.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.ExceptionTranslationFilter;
|
||||
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
|
||||
import org.springframework.security.web.authentication.logout.LogoutFilter;
|
||||
import org.springframework.security.web.context.SecurityContextHolderFilter;
|
||||
|
||||
import com.ankurm.chain.filter.ApiKeyAuthenticationFilter;
|
||||
import com.ankurm.chain.filter.RequestIdFilter;
|
||||
import com.ankurm.chain.filter.TenantFilter;
|
||||
|
||||
/**
|
||||
* Three custom filters at the three placements the reference documentation recommends, plus a
|
||||
* fourth that shows why one of those recommendations is wrong for filters that deny by throwing.
|
||||
*
|
||||
* <pre>
|
||||
* addFilterAfter(RequestIdFilter, SecurityContextHolderFilter) -> 701
|
||||
* addFilterAfter(ApiKeyAuthFilter, LogoutFilter) -> 1201
|
||||
* addFilterAfter(TenantFilter[doc-placement], AnonymousAuthenticationFilter) -> 3701
|
||||
* addFilterAfter(TenantFilter[after-translation], ExceptionTranslationFilter) -> 4001
|
||||
* </pre>
|
||||
*
|
||||
* <p>The two {@code TenantFilter}s are identical code at two orders, 300 apart, straddling
|
||||
* {@code ExceptionTranslationFilter} at 4000. One produces a 403, the other a 500. Which is
|
||||
* which is the point — see {@code docs/05-failure-modes.md}.
|
||||
*
|
||||
* @see <a href="../../../../../../../docs/04-where-custom-filters-land.md">docs/04-where-custom-filters-land.md</a>
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("custom")
|
||||
public class CustomFiltersSecurityConfig {
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain custom(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(Customizer.withDefaults())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.formLogin(Customizer.withDefaults())
|
||||
.authorizeHttpRequests((authorize) -> authorize
|
||||
.requestMatchers("/public/**", "/diag/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
// 700 + 1: an exploit-protection-ish filter, after the context is loaded.
|
||||
.addFilterAfter(new RequestIdFilter(), SecurityContextHolderFilter.class)
|
||||
// 1200 + 1: the documented placement for an authentication filter.
|
||||
.addFilterAfter(new ApiKeyAuthenticationFilter(), LogoutFilter.class)
|
||||
// 3700 + 1: the documented placement for an authorization filter - and BELOW
|
||||
// ExceptionTranslationFilter (4000), so an AccessDeniedException thrown here is
|
||||
// never translated.
|
||||
.addFilterAfter(new TenantFilter("doc-placement", "/tenant/doc"), AnonymousAuthenticationFilter.class)
|
||||
// 4000 + 1: above ExceptionTranslationFilter, so the same throw becomes a 403.
|
||||
.addFilterAfter(new TenantFilter("after-translation", "/tenant/translated"), ExceptionTranslationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ankurm.chain.config;
|
||||
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.csrf.CsrfFilter;
|
||||
|
||||
import com.ankurm.chain.filter.CountingFilter;
|
||||
|
||||
/**
|
||||
* The double-registration trap, and its fix, as two profiles over one piece of code.
|
||||
*
|
||||
* <p>Spring Boot registers <em>every</em> {@code jakarta.servlet.Filter} bean with the servlet
|
||||
* container. Add that same bean to the security chain and it is now in two chains: the
|
||||
* container's, wrapping {@code FilterChainProxy}, and Spring Security's, inside it. It runs
|
||||
* twice for every request, including requests that no {@code SecurityFilterChain} matches.
|
||||
*
|
||||
* <p>Under {@code doublereg} the counting filter is a plain {@code @Bean} and the response
|
||||
* header reports <strong>2</strong>. Under {@code doublereg,fixed} the same bean is wrapped in a
|
||||
* disabled {@code FilterRegistrationBean}, which suppresses the container registration only, and
|
||||
* the header reports <strong>1</strong>.
|
||||
*
|
||||
* @see <a href="../../../../../../../docs/05-failure-modes.md">docs/05-failure-modes.md</a>
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("doublereg")
|
||||
public class DoubleRegistrationConfig {
|
||||
|
||||
@Bean
|
||||
CountingFilter countingFilter() {
|
||||
return new CountingFilter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Present only under {@code doublereg,fixed}. {@code setEnabled(false)} tells Boot not to
|
||||
* register the filter with the container; the bean itself still exists and is still added
|
||||
* to the security chain below.
|
||||
*/
|
||||
@Bean
|
||||
@Profile("fixed")
|
||||
FilterRegistrationBean<CountingFilter> disableContainerRegistration(CountingFilter filter) {
|
||||
FilterRegistrationBean<CountingFilter> registration = new FilterRegistrationBean<>(filter);
|
||||
registration.setEnabled(false);
|
||||
return registration;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain doubleRegistration(HttpSecurity http, CountingFilter filter) throws Exception {
|
||||
http
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll())
|
||||
.addFilterBefore(filter, CsrfFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.ankurm.chain.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* {@code permitAll()} and {@code WebSecurity.ignoring()} sound like synonyms and are not.
|
||||
*
|
||||
* <p>{@code permitAll()} runs the whole chain and then authorizes the request. Security headers
|
||||
* are written, CSRF is enforced, the {@code SecurityContext} is loaded, and
|
||||
* {@code AuthorizationFilter} says yes.
|
||||
*
|
||||
* <p>{@code ignoring()} creates a {@code SecurityFilterChain} with <strong>zero filters</strong>.
|
||||
* You can see it in {@code /diag/chains} as a real, matched, empty chain. No headers, no CSRF,
|
||||
* no context, no anything. It is faster and it is a footgun: anything served under that path is
|
||||
* outside Spring Security entirely.
|
||||
*
|
||||
* @see <a href="../../../../../../../docs/07-multiple-chains.md">docs/07-multiple-chains.md</a>
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("ignoring")
|
||||
public class IgnoringSecurityConfig {
|
||||
|
||||
@Bean
|
||||
WebSecurityCustomizer ignoreStatic() {
|
||||
return (web) -> web.ignoring().requestMatchers("/static/**");
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain ignoring(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(Customizer.withDefaults())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.authorizeHttpRequests((authorize) -> authorize
|
||||
.requestMatchers("/public/**", "/diag/**").permitAll()
|
||||
.anyRequest().authenticated());
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ankurm.chain.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.intercept.AuthorizationFilter;
|
||||
|
||||
import com.ankurm.chain.filter.ApiKeyAuthenticationFilter;
|
||||
|
||||
/**
|
||||
* The same {@link ApiKeyAuthenticationFilter} as the {@code custom} profile, moved <em>one
|
||||
* slot</em> to the wrong side of {@code AuthorizationFilter}.
|
||||
*
|
||||
* <p>{@code addFilterAfter(.., AuthorizationFilter.class)} gives it order 4201. The
|
||||
* authorization decision has already been taken by then, against whatever principal was in the
|
||||
* holder — the anonymous one. A valid key produces a 401, the filter runs anyway, and the
|
||||
* only visible symptom is a rejection that the logs blame on authentication.
|
||||
*
|
||||
* <p>This is the single most common filter-ordering bug and it does not announce itself:
|
||||
* there is no warning, no exception, and the filter demonstrably executes.
|
||||
*
|
||||
* @see <a href="../../../../../../../docs/05-failure-modes.md">docs/05-failure-modes.md</a>
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("misordered")
|
||||
public class MisorderedSecurityConfig {
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain misordered(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
// Identical to the `custom` profile in every respect except the anchor below.
|
||||
.httpBasic(org.springframework.security.config.Customizer.withDefaults())
|
||||
.authorizeHttpRequests((authorize) -> authorize
|
||||
.requestMatchers("/public/**", "/diag/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.addFilterAfter(new ApiKeyAuthenticationFilter(), AuthorizationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.ankurm.chain.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* Three chains, one application. The thing to look at in {@code /diag/chains} is that they are
|
||||
* not the same length: an API chain that turns off sessions, CSRF and form login is materially
|
||||
* shorter than the browser chain, and every filter it drops is a filter that cannot surprise you.
|
||||
*
|
||||
* <p>{@code @Order} on the bean is what fixes evaluation order, and evaluation stops at the
|
||||
* first match. Getting {@code @Order} wrong — or omitting it, which gives
|
||||
* {@code Ordered.LOWEST_PRECEDENCE} and an unspecified relative order — is how a broad
|
||||
* chain ends up shadowing a narrow one. Since Spring Security 6.5 a chain that can never be
|
||||
* reached fails the context at startup rather than being silently dead; the message names the
|
||||
* shadowing chain.
|
||||
*
|
||||
* @see <a href="../../../../../../../docs/07-multiple-chains.md">docs/07-multiple-chains.md</a>
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("multichain")
|
||||
public class MultiChainSecurityConfig {
|
||||
|
||||
/** Stateless, token-ish. No session, no CSRF, no login page, no logout. */
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.securityMatcher("/api/**")
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated());
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/** Diagnostics, wide open, but still a full chain - contrast with the ignoring profile. */
|
||||
@Bean
|
||||
@Order(2)
|
||||
SecurityFilterChain diagChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.securityMatcher("/diag/**", "/public/**")
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll());
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/** Everything else: a browser chain with sessions, CSRF and a generated login page. */
|
||||
@Bean
|
||||
@Order(3)
|
||||
SecurityFilterChain browserChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(Customizer.withDefaults())
|
||||
.formLogin(Customizer.withDefaults())
|
||||
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated());
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ankurm.chain.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.csrf.CsrfFilter;
|
||||
|
||||
import com.ankurm.chain.filter.MarkerFilterA;
|
||||
import com.ankurm.chain.filter.MarkerFilterB;
|
||||
|
||||
/**
|
||||
* Two filters, one anchor, one order number.
|
||||
*
|
||||
* <p>{@code addFilterBefore} computes {@code anchorOrder - 1}, not “a slot before whatever
|
||||
* is already there”. Both of these land on <strong>1099</strong>. The tie is broken by
|
||||
* {@code List.sort}, which is stable, so the filters run in the order the {@code addFilterBefore}
|
||||
* calls were made — a guarantee that comes from {@code java.util.List}, not from anything
|
||||
* Spring Security documents.
|
||||
*
|
||||
* <p>The {@code TIE_REVERSED} system property flips the two calls, which flips the executed
|
||||
* order. That diff is the proof.
|
||||
*
|
||||
* @see <a href="../../../../../../../docs/04-where-custom-filters-land.md">docs/04-where-custom-filters-land.md</a>
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("tie")
|
||||
public class TieSecurityConfig {
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain tie(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll());
|
||||
if (Boolean.getBoolean("TIE_REVERSED")) {
|
||||
http.addFilterBefore(new MarkerFilterB("B"), CsrfFilter.class);
|
||||
http.addFilterBefore(new MarkerFilterA("A"), CsrfFilter.class);
|
||||
}
|
||||
else {
|
||||
http.addFilterBefore(new MarkerFilterA("A"), CsrfFilter.class);
|
||||
http.addFilterBefore(new MarkerFilterB("B"), CsrfFilter.class);
|
||||
}
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.chain.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
|
||||
/**
|
||||
* Two in-memory users, shared by every profile. {@code {noop}} passwords because the subject
|
||||
* of this module is filter ordering, not password storage.
|
||||
*/
|
||||
@Configuration
|
||||
public class UsersConfig {
|
||||
|
||||
@Bean
|
||||
UserDetailsService users() {
|
||||
return new InMemoryUserDetailsManager(
|
||||
User.withUsername("alice").password("{noop}password").roles("USER").build(),
|
||||
User.withUsername("root").password("{noop}password").roles("ADMIN").build());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ankurm.chain.filter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
/**
|
||||
* The shape almost every real project ends up writing: read a credential off the request,
|
||||
* build an {@code Authentication}, put it in the {@code SecurityContextHolder}, carry on.
|
||||
*
|
||||
* <p>Two details matter more than the parsing.
|
||||
*
|
||||
* <p><strong>It must run before {@code AuthorizationFilter} (4200).</strong> That is the
|
||||
* whole reason placement is a topic. The {@code misordered} profile adds this same filter
|
||||
* <em>after</em> {@code AuthorizationFilter} and the result is a 401 with a perfectly valid
|
||||
* key — the authorization decision was already taken against an anonymous principal.
|
||||
*
|
||||
* <p><strong>It creates the context with {@code createEmptyContext()} rather than mutating the
|
||||
* one already in the holder.</strong> Since Spring Security 6, {@code SecurityContextHolderFilter}
|
||||
* does not save the context back to the session, so mutating the existing instance is both a
|
||||
* shared-state hazard and pointless.
|
||||
*
|
||||
* @see <a href="../../../../../../../docs/04-where-custom-filters-land.md">docs/04-where-custom-filters-land.md</a>
|
||||
* @see <a href="../../../../../../../docs/05-failure-modes.md">docs/05-failure-modes.md</a>
|
||||
*/
|
||||
public class ApiKeyAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
/** Not a credential store. The point of this class is the placement, not the parsing. */
|
||||
private static final String VALID_KEY = "let-me-in";
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
String key = request.getHeader("X-Api-Key");
|
||||
if (VALID_KEY.equals(key)) {
|
||||
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken
|
||||
.authenticated("api-client", null, List.of(new SimpleGrantedAuthority("ROLE_API")));
|
||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(authentication);
|
||||
SecurityContextHolder.setContext(context);
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.ankurm.chain.filter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.springframework.web.filter.GenericFilterBean;
|
||||
|
||||
/**
|
||||
* Counts its own invocations and reports the count in a response header, so the
|
||||
* double-registration trap becomes a number rather than an argument.
|
||||
*
|
||||
* <p>Deliberately extends {@code GenericFilterBean} rather than {@code OncePerRequestFilter}.
|
||||
* {@code OncePerRequestFilter} would mask the bug: it sets a request attribute on first entry
|
||||
* and short-circuits on the second, so the filter <em>is</em> still registered twice, still
|
||||
* consumes a stack frame twice, and still runs twice in every dispatch that gets a fresh
|
||||
* request — but the counter would read 1 and you would conclude there was no problem.
|
||||
*
|
||||
* @see <a href="../../../../../../../docs/05-failure-modes.md">docs/05-failure-modes.md</a>
|
||||
*/
|
||||
public class CountingFilter extends GenericFilterBean {
|
||||
|
||||
/** Total across the application's lifetime, for the assertions. */
|
||||
private final AtomicInteger total = new AtomicInteger();
|
||||
|
||||
@Override
|
||||
public void doFilter(jakarta.servlet.ServletRequest request, jakarta.servlet.ServletResponse response,
|
||||
FilterChain chain) throws IOException, ServletException {
|
||||
this.total.incrementAndGet();
|
||||
// Per-request, so the header answers "how many times did this filter run for THIS
|
||||
// request" rather than "how many requests has the application served".
|
||||
int perRequest = 1;
|
||||
if (request instanceof HttpServletRequest httpRequest) {
|
||||
Object previous = httpRequest.getAttribute(ATTRIBUTE);
|
||||
perRequest = (previous instanceof Integer count) ? count + 1 : 1;
|
||||
httpRequest.setAttribute(ATTRIBUTE, perRequest);
|
||||
}
|
||||
if (response instanceof HttpServletResponse httpResponse) {
|
||||
httpResponse.setHeader("X-Counting-Filter-Invocations", String.valueOf(perRequest));
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
public static final String ATTRIBUTE = "countingFilterInvocations";
|
||||
|
||||
public int total() {
|
||||
return this.total.get();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ankurm.chain.filter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
/**
|
||||
* Appends its label to a request-scoped list, so the <em>executed</em> order of a set of
|
||||
* filters can be read off the response instead of inferred from configuration.
|
||||
*
|
||||
* <p>Used by the {@code tie} profile, where two filters are added before the same anchor and
|
||||
* therefore receive the same order number.
|
||||
*
|
||||
* @see <a href="../../../../../../../docs/04-where-custom-filters-land.md">docs/04-where-custom-filters-land.md</a>
|
||||
*/
|
||||
public class MarkerFilter extends OncePerRequestFilter {
|
||||
|
||||
public static final String ATTRIBUTE = "com.ankurm.chain.markers";
|
||||
|
||||
private final String label;
|
||||
|
||||
public MarkerFilter(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
List<String> markers = (List<String>) request.getAttribute(ATTRIBUTE);
|
||||
if (markers == null) {
|
||||
markers = new ArrayList<>();
|
||||
request.setAttribute(ATTRIBUTE, markers);
|
||||
}
|
||||
markers.add(this.label);
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return getClass().getSimpleName() + "[" + this.label + "]";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ankurm.chain.filter;
|
||||
|
||||
/**
|
||||
* A distinct class from {@link MarkerFilterB} so that both can be registered against the same
|
||||
* anchor and receive the same order number without one overwriting the other in
|
||||
* {@code FilterOrderRegistration}. See {@code docs/04-where-custom-filters-land.md}.
|
||||
*/
|
||||
public class MarkerFilterA extends MarkerFilter {
|
||||
|
||||
public MarkerFilterA(String label) {
|
||||
super(label);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.chain.filter;
|
||||
|
||||
/** @see MarkerFilterA */
|
||||
public class MarkerFilterB extends MarkerFilter {
|
||||
|
||||
public MarkerFilterB(String label) {
|
||||
super(label);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ankurm.chain.filter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
/**
|
||||
* A filter with no security opinion at all: it stamps a correlation id on the response.
|
||||
*
|
||||
* <p>It is in this repository to make one point — a filter like this does not need to be
|
||||
* in the security chain. Put it in the servlet chain and it wraps <em>everything</em>, including
|
||||
* the requests {@code FilterChainProxy} rejects before any of your code runs. Put it in the
|
||||
* security chain and requests that fail the {@code HttpFirewall}, or that match a different
|
||||
* {@code SecurityFilterChain}, never reach it.
|
||||
*
|
||||
* <p>Extending {@code OncePerRequestFilter} deliberately: see
|
||||
* {@code docs/04-where-custom-filters-land.md} for why that class is <em>not</em> in the order
|
||||
* table and what that means for {@code http.addFilter(..)}.
|
||||
*
|
||||
* @see <a href="../../../../../../../docs/04-where-custom-filters-land.md">docs/04-where-custom-filters-land.md</a>
|
||||
*/
|
||||
public class RequestIdFilter extends OncePerRequestFilter {
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
response.setHeader("X-Request-Id", UUID.nameUUIDFromBytes(
|
||||
(request.getMethod() + request.getRequestURI()).getBytes()).toString());
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.ankurm.chain.filter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
/**
|
||||
* Deliberately modelled on the {@code TenantFilter} in the Spring Security reference
|
||||
* documentation, because it hides a placement trap that the documentation does not mention.
|
||||
*
|
||||
* <p>It signals refusal by throwing {@link AccessDeniedException}. Nothing in the servlet API
|
||||
* turns that into a 403 — {@code ExceptionTranslationFilter} does, and it sits at order
|
||||
* <strong>4000</strong>. Throw this exception from a filter placed at any order below 4000 and
|
||||
* it propagates straight out of {@code FilterChainProxy} to the container, which renders it as
|
||||
* <strong>500 Internal Server Error</strong>.
|
||||
*
|
||||
* <p>The documented placement (after {@code AnonymousAuthenticationFilter}, order 3701) is
|
||||
* below 4000 and therefore does not produce the 403 the example implies. The {@code custom}
|
||||
* profile shows both placements side by side; the captured proof is in
|
||||
* {@code docs/output/demo6-exception-translation.txt}.
|
||||
*
|
||||
* <p>There is a second trap in putting two instances of this class in one chain, and it cost a
|
||||
* test failure to find. {@code OncePerRequestFilter} guards against double execution with a
|
||||
* request attribute named {@code getFilterName() + ".FILTERED"}, and for a filter that is not a
|
||||
* Spring bean {@code getFilterName()} falls back to the <em>class name</em>. Two instances of
|
||||
* the same {@code OncePerRequestFilter} subclass therefore share one attribute, and
|
||||
* <strong>the second one silently never runs</strong>. Set {@code -DUNIQUE_ONCE_KEY=true} to
|
||||
* give each instance its own key and watch the second filter come back to life.
|
||||
*
|
||||
* @see <a href="../../../../../../../docs/05-failure-modes.md">docs/05-failure-modes.md</a>
|
||||
*/
|
||||
public class TenantFilter extends OncePerRequestFilter {
|
||||
|
||||
private final String label;
|
||||
|
||||
private final String guardedPrefix;
|
||||
|
||||
/**
|
||||
* @param label which of the two instances this is, so the captured output says which one
|
||||
* threw
|
||||
* @param guardedPrefix only requests under this path are checked, so that the two instances
|
||||
* can coexist in one chain and be triggered independently
|
||||
*/
|
||||
public TenantFilter(String label, String guardedPrefix) {
|
||||
this.label = label;
|
||||
this.guardedPrefix = guardedPrefix;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
if (!request.getRequestURI().startsWith(this.guardedPrefix)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
String tenant = request.getHeader("X-Tenant-Id");
|
||||
if ("acme".equals(tenant)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
throw new AccessDeniedException("tenant '" + tenant + "' rejected by " + this.label);
|
||||
}
|
||||
|
||||
/**
|
||||
* With the JVM flag set, each instance gets its own already-filtered key. Without it, both
|
||||
* instances answer {@code "com.ankurm.chain.filter.TenantFilter.FILTERED"} and the second
|
||||
* one to run finds the attribute already set and skips itself.
|
||||
*/
|
||||
@Override
|
||||
protected String getAlreadyFilteredAttributeName() {
|
||||
String base = super.getAlreadyFilteredAttributeName();
|
||||
return Boolean.getBoolean("UNIQUE_ONCE_KEY") ? base + "." + this.label : base;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return getClass().getSimpleName() + "[" + this.label + "]";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.ankurm.chain.support;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
|
||||
/**
|
||||
* Reads {@code org.springframework.security.config.annotation.web.builders.FilterOrderRegistration}
|
||||
* — the package-private class that decides the order of every filter in the chain.
|
||||
*
|
||||
* <p>It is not public API and there is no supported way to see it. Reflection is honest about
|
||||
* that: the class is instantiated with its no-arg constructor and its {@code filterToOrder} map
|
||||
* is read out verbatim. Nothing here is a transcription of the source; if Spring Security
|
||||
* renumbers a slot, this table changes with it.
|
||||
*
|
||||
* <p>The map is keyed by <em>class name string</em>, not by {@code Class}, precisely because
|
||||
* several entries name classes that may not be on the classpath — the optional
|
||||
* {@code oauth2-client}, {@code saml2}, {@code cas} and {@code oauth2-resource-server} filters.
|
||||
* As of Spring Security 7 two of those string entries name classes that no longer exist
|
||||
* <em>anywhere</em>: {@code ChannelProcessingFilter} (300) and {@code FilterSecurityInterceptor}
|
||||
* (4100), both removed in 7.0. Their slots were kept so that no other number had to move.
|
||||
*
|
||||
* @see <a href="../../../../../../../docs/03-the-order-table.md">docs/03-the-order-table.md</a>
|
||||
*/
|
||||
public final class FilterOrderTable {
|
||||
|
||||
private static final String REGISTRATION_CLASS =
|
||||
"org.springframework.security.config.annotation.web.builders.FilterOrderRegistration";
|
||||
|
||||
private static final Map<String, Integer> TABLE = read();
|
||||
|
||||
private FilterOrderTable() {
|
||||
}
|
||||
|
||||
/** The whole registration table, keyed by fully-qualified class name. */
|
||||
public static Map<String, Integer> table() {
|
||||
return TABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The order Spring Security would give this filter type, resolved the same way
|
||||
* {@code FilterOrderRegistration.getOrder(Class)} resolves it — by walking up the
|
||||
* superclass chain until a registered type is found. That walk is why a subclass of
|
||||
* {@code UsernamePasswordAuthenticationFilter} can be passed to {@code http.addFilter(..)}
|
||||
* with no further configuration, and why a filter extending {@code OncePerRequestFilter}
|
||||
* cannot: {@code OncePerRequestFilter} is not in the table.
|
||||
*
|
||||
* @return the order, or {@code null} if no ancestor of this type is registered
|
||||
*/
|
||||
public static Integer orderOf(Class<?> filterType) {
|
||||
for (Class<?> type = filterType; type != null; type = type.getSuperclass()) {
|
||||
Integer order = TABLE.get(type.getName());
|
||||
if (order != null) {
|
||||
return order;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a name in the table resolves to a class that is actually present. */
|
||||
public static boolean isOnClasspath(String className) {
|
||||
try {
|
||||
Class.forName(className, false, FilterOrderTable.class.getClassLoader());
|
||||
return true;
|
||||
}
|
||||
catch (ClassNotFoundException | LinkageError ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What {@code http.addFilterBefore(filter, anchor)} would compute, so a test can assert the
|
||||
* number rather than eyeball the chain. {@code addFilterAtOffsetOf} adds the offset to the
|
||||
* anchor's registered order: -1 for before, +1 for after, 0 for at.
|
||||
*/
|
||||
public static Integer orderIfAddedBefore(Class<? extends Filter> anchor) {
|
||||
Integer anchorOrder = orderOf(anchor);
|
||||
return (anchorOrder != null) ? anchorOrder - 1 : null;
|
||||
}
|
||||
|
||||
/** @see #orderIfAddedBefore(Class) */
|
||||
public static Integer orderIfAddedAfter(Class<? extends Filter> anchor) {
|
||||
Integer anchorOrder = orderOf(anchor);
|
||||
return (anchorOrder != null) ? anchorOrder + 1 : null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Integer> read() {
|
||||
try {
|
||||
Class<?> type = Class.forName(REGISTRATION_CLASS);
|
||||
Constructor<?> constructor = type.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
Object registration = constructor.newInstance();
|
||||
Field field = type.getDeclaredField("filterToOrder");
|
||||
field.setAccessible(true);
|
||||
return Collections.unmodifiableMap(new LinkedHashMap<>((Map<String, Integer>) field.get(registration)));
|
||||
}
|
||||
catch (ReflectiveOperationException ex) {
|
||||
throw new IllegalStateException("Could not read " + REGISTRATION_CLASS
|
||||
+ ". If this fails on a future Spring Security version, the class or its "
|
||||
+ "field was renamed - which is itself worth knowing.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.ankurm.chain.web;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.ankurm.chain.filter.MarkerFilter;
|
||||
|
||||
/**
|
||||
* Endpoints the scripts drive. Each one exists to make one thing observable.
|
||||
*/
|
||||
@RestController
|
||||
public class DemoController {
|
||||
|
||||
/** Who the chain decided you are, by the time a controller runs. */
|
||||
@GetMapping(value = "/whoami", produces = "text/plain")
|
||||
public String whoami(HttpServletRequest request) {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null) {
|
||||
return "authentication=null (no filter put one in the holder)\n";
|
||||
}
|
||||
return "principal=" + authentication.getName()
|
||||
+ " authenticated=" + authentication.isAuthenticated()
|
||||
+ " authorities=" + authentication.getAuthorities()
|
||||
+ " type=" + authentication.getClass().getSimpleName()
|
||||
+ " counting-filter-invocations=" + request.getAttribute("countingFilterInvocations") + "\n";
|
||||
}
|
||||
|
||||
@GetMapping(value = "/public/hello", produces = "text/plain")
|
||||
public String publicHello() {
|
||||
return "public ok\n";
|
||||
}
|
||||
|
||||
@GetMapping(value = "/api/data", produces = "text/plain")
|
||||
public String apiData() {
|
||||
return "api ok\n";
|
||||
}
|
||||
|
||||
@GetMapping(value = "/static/asset.txt", produces = "text/plain")
|
||||
public String staticAsset() {
|
||||
return "static ok\n";
|
||||
}
|
||||
|
||||
@PostMapping(value = "/hello", produces = "text/plain")
|
||||
public String postHello() {
|
||||
return "post ok\n";
|
||||
}
|
||||
|
||||
/** Guarded by the TenantFilter placed BELOW ExceptionTranslationFilter (order 3701). */
|
||||
@GetMapping(value = "/tenant/doc", produces = "text/plain")
|
||||
public String tenantDoc() {
|
||||
return "tenant/doc ok\n";
|
||||
}
|
||||
|
||||
/** Guarded by the TenantFilter placed ABOVE ExceptionTranslationFilter (order 4001). */
|
||||
@GetMapping(value = "/tenant/translated", produces = "text/plain")
|
||||
public String tenantTranslated() {
|
||||
return "tenant/translated ok\n";
|
||||
}
|
||||
|
||||
/** The executed order of the two tied marker filters, read off the request. */
|
||||
@SuppressWarnings("unchecked")
|
||||
@GetMapping(value = "/markers", produces = "text/plain")
|
||||
public String markers(HttpServletRequest request) {
|
||||
List<String> markers = (List<String>) request.getAttribute(MarkerFilter.ATTRIBUTE);
|
||||
return "executed order: " + markers + "\n";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.ankurm.chain.web;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
import jakarta.servlet.FilterRegistration;
|
||||
import jakarta.servlet.ServletContext;
|
||||
|
||||
import org.springframework.security.web.DefaultSecurityFilterChain;
|
||||
import org.springframework.security.web.FilterChainProxy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.ankurm.chain.support.FilterOrderTable;
|
||||
|
||||
/**
|
||||
* Prints the security plumbing that is otherwise invisible at runtime.
|
||||
*
|
||||
* <p>Three views, and they answer three different questions:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code /diag/chains} — what {@link FilterChainProxy} actually holds. This is the
|
||||
* only authoritative answer to “which filters run for my request”, because it
|
||||
* is read back out of the live bean rather than inferred from configuration.</li>
|
||||
* <li>{@code /diag/order} — the {@code FilterOrderRegistration} table, reflected out of
|
||||
* {@code spring-security-config}. This is the sort key {@code HttpSecurity} uses, and it
|
||||
* is what determines where {@code addFilterBefore(..)} puts your filter.</li>
|
||||
* <li>{@code /diag/servlet-filters} — what the servlet container has registered. A
|
||||
* custom security filter that appears here <em>as well as</em> in {@code /diag/chains}
|
||||
* runs twice per request.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><strong>Delete this controller before shipping.</strong> It publishes your entire security
|
||||
* topology to anyone who can reach it.
|
||||
*
|
||||
* @see <a href="../../../../../../../docs/02-the-default-chain.md">docs/02-the-default-chain.md</a>
|
||||
* @see <a href="../../../../../../../docs/04-where-custom-filters-land.md">docs/04-where-custom-filters-land.md</a>
|
||||
*/
|
||||
@RestController
|
||||
public class DiagnosticsController {
|
||||
|
||||
private final FilterChainProxy filterChainProxy;
|
||||
|
||||
private final ServletContext servletContext;
|
||||
|
||||
public DiagnosticsController(FilterChainProxy filterChainProxy, ServletContext servletContext) {
|
||||
this.filterChainProxy = filterChainProxy;
|
||||
this.servletContext = servletContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every {@link SecurityFilterChain} the proxy holds, in the order it evaluates them, with
|
||||
* each chain's filters in the order they will run.
|
||||
*
|
||||
* <p>Note the numbering: it matches the {@code (n/m)} counters in {@code FilterChainProxy}'s
|
||||
* TRACE output exactly, because both come from the same list.
|
||||
*/
|
||||
@GetMapping(value = "/diag/chains", produces = "text/plain")
|
||||
public String chains() {
|
||||
StringBuilder out = new StringBuilder();
|
||||
List<SecurityFilterChain> chains = this.filterChainProxy.getFilterChains();
|
||||
out.append("FilterChainProxy holds ").append(chains.size()).append(" SecurityFilterChain(s).\n");
|
||||
out.append("The FIRST chain whose matcher accepts the request wins; the rest are never consulted.\n");
|
||||
int chainIndex = 0;
|
||||
for (SecurityFilterChain chain : chains) {
|
||||
chainIndex++;
|
||||
List<Filter> filters = chain.getFilters();
|
||||
out.append("\n=== chain ").append(chainIndex).append('/').append(chains.size())
|
||||
.append(" matcher = ").append(describeMatcher(chain))
|
||||
.append(" (").append(filters.size()).append(" filters)\n");
|
||||
if (filters.isEmpty()) {
|
||||
out.append(" <no filters> - this chain does NOTHING. Requests it matches are\n");
|
||||
out.append(" unauthenticated, unauthorised, and get no security headers.\n");
|
||||
continue;
|
||||
}
|
||||
int i = 0;
|
||||
for (Filter filter : filters) {
|
||||
i++;
|
||||
String name = filter.getClass().getSimpleName();
|
||||
Integer order = FilterOrderTable.orderOf(filter.getClass());
|
||||
out.append(String.format(" %2d/%d %-52s %s%n", i, filters.size(), name,
|
||||
(order != null) ? "order=" + order : "order=? (not in the registration table)"));
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration table itself. Orders 300 and 4100 are printed even though the classes
|
||||
* they name were removed in Spring Security 7 — see
|
||||
* {@code docs/03-the-order-table.md}.
|
||||
*/
|
||||
@GetMapping(value = "/diag/order", produces = "text/plain")
|
||||
public String order() {
|
||||
StringBuilder out = new StringBuilder();
|
||||
out.append("FilterOrderRegistration, reflected out of spring-security-config.\n");
|
||||
out.append("Slots are 100 apart, starting at 100. addFilterBefore = slot-1, addFilterAfter = slot+1.\n\n");
|
||||
Map<String, Integer> table = FilterOrderTable.table();
|
||||
List<Map.Entry<String, Integer>> entries = new ArrayList<>(table.entrySet());
|
||||
entries.sort(Comparator.comparing(Map.Entry::getValue));
|
||||
int expected = 100;
|
||||
for (Map.Entry<String, Integer> entry : entries) {
|
||||
while (expected < entry.getValue()) {
|
||||
out.append(String.format(" %4d %s%n", expected, "-- reserved, nothing registered --"));
|
||||
expected += 100;
|
||||
}
|
||||
boolean present = FilterOrderTable.isOnClasspath(entry.getKey());
|
||||
out.append(String.format(" %4d %-88s %s%n", entry.getValue(), entry.getKey(),
|
||||
present ? "" : "<-- CLASS NOT ON CLASSPATH"));
|
||||
expected = entry.getValue() + 100;
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* What the servlet container itself has registered, ahead of and around
|
||||
* {@code FilterChainProxy}. The security chain is a single entry here
|
||||
* (“springSecurityFilterChain”, a {@code DelegatingFilterProxy}); everything
|
||||
* inside it is invisible to the container.
|
||||
*/
|
||||
@GetMapping(value = "/diag/servlet-filters", produces = "text/plain")
|
||||
public String servletFilters() {
|
||||
StringBuilder out = new StringBuilder("Filters registered with the servlet container:\n\n");
|
||||
Map<String, ? extends FilterRegistration> registrations = this.servletContext.getFilterRegistrations();
|
||||
registrations.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.forEach((entry) -> out.append(String.format(" %-46s %s urls=%s%n", entry.getKey(),
|
||||
entry.getValue().getClassName(), entry.getValue().getUrlPatternMappings())));
|
||||
out.append("\nAnything listed here runs OUTSIDE FilterChainProxy. A custom security filter that\n");
|
||||
out.append("appears both here and in /diag/chains will run twice for every request.\n");
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private String describeMatcher(SecurityFilterChain chain) {
|
||||
if (chain instanceof DefaultSecurityFilterChain defaultChain) {
|
||||
return String.valueOf(defaultChain.getRequestMatcher());
|
||||
}
|
||||
return chain.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
}
|
||||
15
filter-chain/src/main/resources/application.properties
Normal file
15
filter-chain/src/main/resources/application.properties
Normal file
@@ -0,0 +1,15 @@
|
||||
# Baseline is the reference configuration; every other scenario is an explicit profile.
|
||||
spring.profiles.default=baseline
|
||||
|
||||
# Stable output: no banner, no random port, no startup timing noise in the captured files.
|
||||
spring.main.banner-mode=off
|
||||
server.port=8080
|
||||
|
||||
# The chain that Spring Security prints at startup is a DEBUG log on
|
||||
# o.s.s.web.DefaultSecurityFilterChain; per-request filter invocations are TRACE on
|
||||
# o.s.security.web.FilterChainProxy. scripts/run.sh raises this to TRACE on demand.
|
||||
logging.level.org.springframework.security=INFO
|
||||
logging.pattern.console=%-5level %logger{39} : %msg%n
|
||||
|
||||
# Actuator is on the classpath for the observation demo only; keep its endpoints out of the way.
|
||||
management.endpoints.web.exposure.include=health
|
||||
@@ -0,0 +1,280 @@
|
||||
package com.ankurm.chain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.security.web.DefaultSecurityFilterChain;
|
||||
import org.springframework.security.web.FilterChainProxy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* The claims the article makes about this application, pinned as assertions.
|
||||
*
|
||||
* <p>Each nested class is one profile, because profiles are fixed at context startup. The
|
||||
* captured transcripts in {@code docs/output/} come from the same profiles driven over HTTP;
|
||||
* these tests are the machine-checkable version of the same facts.
|
||||
*
|
||||
* @see <a href="../../../../../../docs/09-testing-the-chain.md">docs/09-testing-the-chain.md</a>
|
||||
*/
|
||||
class FilterChainContractTest {
|
||||
|
||||
private static List<String> filterNames(SecurityFilterChain chain) {
|
||||
return chain.getFilters().stream().map((filter) -> filter.getClass().getSimpleName()).toList();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("baseline")
|
||||
@DisplayName("the reference configuration")
|
||||
class Baseline {
|
||||
|
||||
@Autowired
|
||||
FilterChainProxy proxy;
|
||||
|
||||
@Test
|
||||
@DisplayName("produces exactly sixteen filters, in this order")
|
||||
void defaultChainIsSixteenFilters() {
|
||||
assertThat(this.proxy.getFilterChains()).hasSize(1);
|
||||
assertThat(filterNames(this.proxy.getFilterChains().get(0))).containsExactly(
|
||||
"DisableEncodeUrlFilter",
|
||||
"WebAsyncManagerIntegrationFilter",
|
||||
"SecurityContextHolderFilter",
|
||||
"HeaderWriterFilter",
|
||||
"CsrfFilter",
|
||||
"LogoutFilter",
|
||||
"UsernamePasswordAuthenticationFilter",
|
||||
"DefaultResourcesFilter",
|
||||
"DefaultLoginPageGeneratingFilter",
|
||||
"DefaultLogoutPageGeneratingFilter",
|
||||
"BasicAuthenticationFilter",
|
||||
"RequestCacheAwareFilter",
|
||||
"SecurityContextHolderAwareRequestFilter",
|
||||
"AnonymousAuthenticationFilter",
|
||||
"ExceptionTranslationFilter",
|
||||
"AuthorizationFilter");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does NOT contain SessionManagementFilter, which many articles still list")
|
||||
void sessionManagementFilterIsAbsentByDefault() {
|
||||
assertThat(filterNames(this.proxy.getFilterChains().get(0)))
|
||||
.doesNotContain("SessionManagementFilter", "SecurityContextPersistenceFilter");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("every filter in the chain resolves to a slot in the order table, ascending")
|
||||
void chainIsSortedByTheOrderTable() {
|
||||
List<Filter> filters = this.proxy.getFilterChains().get(0).getFilters();
|
||||
List<Integer> orders = filters.stream()
|
||||
.map((filter) -> com.ankurm.chain.support.FilterOrderTable.orderOf(filter.getClass()))
|
||||
.toList();
|
||||
assertThat(orders).doesNotContainNull().isSorted();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("custom")
|
||||
@DisplayName("custom filters")
|
||||
class Custom {
|
||||
|
||||
@Autowired
|
||||
FilterChainProxy proxy;
|
||||
|
||||
@Autowired
|
||||
MockMvc mvc;
|
||||
|
||||
@Test
|
||||
@DisplayName("land exactly where their anchor's order puts them")
|
||||
void customFiltersLandNextToTheirAnchors() {
|
||||
List<String> names = filterNames(this.proxy.getFilterChains().get(0));
|
||||
assertThat(names).containsSubsequence("SecurityContextHolderFilter", "RequestIdFilter", "HeaderWriterFilter");
|
||||
assertThat(names).containsSubsequence("LogoutFilter", "ApiKeyAuthenticationFilter",
|
||||
"UsernamePasswordAuthenticationFilter");
|
||||
assertThat(names).containsSubsequence("AnonymousAuthenticationFilter", "TenantFilter",
|
||||
"ExceptionTranslationFilter", "TenantFilter", "AuthorizationFilter");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an AccessDeniedException thrown BELOW ExceptionTranslationFilter is not translated")
|
||||
void accessDeniedBelowTranslationIsNotTranslated() {
|
||||
// The exception comes straight back out of FilterChainProxy with no cause and no
|
||||
// status code. In a real container that is a 500; MockMvc shows you the throw
|
||||
// itself, which is the same fact seen from the other side. The HTTP-level proof is
|
||||
// docs/output/demo6-exception-translation.txt.
|
||||
assertThat(org.assertj.core.api.Assertions.catchThrowable(
|
||||
() -> this.mvc.perform(get("/tenant/doc").with(user()))))
|
||||
.isInstanceOf(org.springframework.security.access.AccessDeniedException.class)
|
||||
.hasMessageContaining("doc-placement");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the second instance of the same OncePerRequestFilter subclass never runs")
|
||||
void twoInstancesOfOneOncePerRequestFilterShareTheirAlreadyFilteredKey() throws Exception {
|
||||
// Both TenantFilters are in the chain - customFiltersLandNextToTheirAnchors proves
|
||||
// it. But they answer the same getAlreadyFilteredAttributeName(), so the one at
|
||||
// order 3701 marks the request and the one at 4001 skips itself. The guarded path
|
||||
// sails through with a 200. Run with -DUNIQUE_ONCE_KEY=true and this becomes a 403.
|
||||
assertThat(System.getProperty("UNIQUE_ONCE_KEY")).isNull();
|
||||
this.mvc.perform(get("/tenant/translated").with(user())).andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the api-key filter at order 1201 authenticates before authorization runs")
|
||||
void apiKeyFilterAuthenticatesInTime() throws Exception {
|
||||
this.mvc.perform(get("/whoami").header("X-Api-Key", "let-me-in"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the request-id filter at order 701 runs for permitted requests too")
|
||||
void requestIdFilterRuns() throws Exception {
|
||||
this.mvc.perform(get("/public/hello")).andExpect(header().exists("X-Request-Id"));
|
||||
}
|
||||
|
||||
private static org.springframework.test.web.servlet.request.RequestPostProcessor user() {
|
||||
return org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
|
||||
.user("alice").roles("USER");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("misordered")
|
||||
@DisplayName("an authentication filter after AuthorizationFilter")
|
||||
class Misordered {
|
||||
|
||||
@Autowired
|
||||
MockMvc mvc;
|
||||
|
||||
@Autowired
|
||||
FilterChainProxy proxy;
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a valid credential, because the decision was already taken")
|
||||
void validKeyIsStillRejected() throws Exception {
|
||||
this.mvc.perform(get("/whoami").header("X-Api-Key", "let-me-in"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("and the filter demonstrably did run - it is in the chain, one slot too late")
|
||||
void theFilterIsStillInTheChain() {
|
||||
assertThat(this.proxy.getFilterChains().get(0).getFilters().stream()
|
||||
.map((filter) -> filter.getClass().getSimpleName()).toList())
|
||||
.containsSubsequence("AuthorizationFilter", "ApiKeyAuthenticationFilter");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("tie")
|
||||
@DisplayName("two filters added before the same anchor")
|
||||
class Tie {
|
||||
|
||||
@Autowired
|
||||
FilterChainProxy proxy;
|
||||
|
||||
@Test
|
||||
@DisplayName("run in the order they were added")
|
||||
void tiedFiltersKeepRegistrationOrder() {
|
||||
assertThat(filterNames(this.proxy.getFilterChains().get(0)))
|
||||
.containsSubsequence("MarkerFilterA", "MarkerFilterB");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("land at the anchor's slot even though the anchor is not in the chain")
|
||||
void theAnchorNeedNotBePresent() {
|
||||
// This profile calls csrf().disable(), so there is no CsrfFilter to be 'before'.
|
||||
// addFilterBefore resolves the anchor against the STATIC order table, not against
|
||||
// the chain being built, so both filters still land on 1099 - between
|
||||
// HeaderWriterFilter (900) and LogoutFilter (1200).
|
||||
List<String> names = filterNames(this.proxy.getFilterChains().get(0));
|
||||
assertThat(names).doesNotContain("CsrfFilter");
|
||||
assertThat(names).containsSubsequence("HeaderWriterFilter", "MarkerFilterA", "MarkerFilterB",
|
||||
"LogoutFilter");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("ignoring")
|
||||
@DisplayName("WebSecurity.ignoring()")
|
||||
class Ignoring {
|
||||
|
||||
@Autowired
|
||||
FilterChainProxy proxy;
|
||||
|
||||
@Autowired
|
||||
MockMvc mvc;
|
||||
|
||||
@Test
|
||||
@DisplayName("creates a real chain with zero filters")
|
||||
void ignoringCreatesAnEmptyChain() {
|
||||
List<SecurityFilterChain> chains = this.proxy.getFilterChains();
|
||||
assertThat(chains).hasSizeGreaterThan(1);
|
||||
SecurityFilterChain first = chains.get(0);
|
||||
assertThat(first.getFilters()).isEmpty();
|
||||
assertThat(first).isInstanceOf(DefaultSecurityFilterChain.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("means no security headers at all on the ignored path")
|
||||
void ignoredPathGetsNoSecurityHeaders() throws Exception {
|
||||
this.mvc.perform(get("/static/asset.txt"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().doesNotExist("X-Content-Type-Options"));
|
||||
this.mvc.perform(get("/public/hello"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("X-Content-Type-Options", "nosniff"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("multichain")
|
||||
@DisplayName("three SecurityFilterChain beans")
|
||||
class MultiChain {
|
||||
|
||||
@Autowired
|
||||
FilterChainProxy proxy;
|
||||
|
||||
@Test
|
||||
@DisplayName("are evaluated in @Order and the api chain is materially shorter")
|
||||
void chainsAreOrderedAndDifferentLengths() {
|
||||
List<SecurityFilterChain> chains = this.proxy.getFilterChains();
|
||||
assertThat(chains).hasSize(3);
|
||||
assertThat(chains.get(0).getFilters().size()).isLessThan(chains.get(2).getFilters().size());
|
||||
assertThat(filterNames(chains.get(0))).doesNotContain("CsrfFilter", "DefaultLoginPageGeneratingFilter");
|
||||
assertThat(filterNames(chains.get(2))).contains("CsrfFilter", "DefaultLoginPageGeneratingFilter");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.ankurm.chain;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.security.web.access.ExceptionTranslationFilter;
|
||||
import org.springframework.security.web.access.intercept.AuthorizationFilter;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.csrf.CsrfFilter;
|
||||
import org.springframework.security.web.session.DisableEncodeUrlFilter;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import com.ankurm.chain.filter.RequestIdFilter;
|
||||
import com.ankurm.chain.support.FilterOrderTable;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Facts about the ordering table itself. No application context: these are properties of
|
||||
* {@code spring-security-config}, not of this application.
|
||||
*
|
||||
* @see <a href="../../../../../../docs/03-the-order-table.md">docs/03-the-order-table.md</a>
|
||||
*/
|
||||
class FilterOrderTableTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("slots are 100 apart and start at 100")
|
||||
void slotsAreOneHundredApart() {
|
||||
assertThat(FilterOrderTable.orderOf(DisableEncodeUrlFilter.class)).isEqualTo(100);
|
||||
assertThat(FilterOrderTable.orderOf(CsrfFilter.class)).isEqualTo(1100);
|
||||
assertThat(FilterOrderTable.orderOf(ExceptionTranslationFilter.class)).isEqualTo(4000);
|
||||
assertThat(FilterOrderTable.orderOf(AuthorizationFilter.class)).isEqualTo(4200);
|
||||
assertThat(FilterOrderTable.table().values()).allSatisfy((order) -> assertThat(order % 100).isZero());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addFilterBefore is anchor-1 and addFilterAfter is anchor+1, not 'the next free slot'")
|
||||
void offsetsAreExactlyOne() {
|
||||
assertThat(FilterOrderTable.orderIfAddedBefore(CsrfFilter.class)).isEqualTo(1099);
|
||||
assertThat(FilterOrderTable.orderIfAddedAfter(CsrfFilter.class)).isEqualTo(1101);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getOrder walks up the superclass chain")
|
||||
void resolutionWalksSuperclasses() {
|
||||
class MyLoginFilter extends UsernamePasswordAuthenticationFilter {
|
||||
}
|
||||
assertThat(FilterOrderTable.orderOf(MyLoginFilter.class)).isEqualTo(2100);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("OncePerRequestFilter is NOT in the table, so a plain custom filter has no order")
|
||||
void oncePerRequestFilterIsNotRegistered() {
|
||||
assertThat(FilterOrderTable.table()).doesNotContainKey(OncePerRequestFilter.class.getName());
|
||||
assertThat(FilterOrderTable.orderOf(RequestIdFilter.class)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two slots are reserved and hold nothing: 500 and 2300")
|
||||
void reservedSlotsAreEmpty() {
|
||||
assertThat(FilterOrderTable.table().values()).doesNotContain(500, 2300);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("orders 300 and 4100 name classes that no longer exist in Spring Security 7")
|
||||
void removedFiltersStillOccupyTheirSlots() {
|
||||
Map<String, Integer> table = FilterOrderTable.table();
|
||||
String channel = "org.springframework.security.web.access.channel.ChannelProcessingFilter";
|
||||
String interceptor = "org.springframework.security.web.access.intercept.FilterSecurityInterceptor";
|
||||
|
||||
assertThat(table).containsEntry(channel, 300).containsEntry(interceptor, 4100);
|
||||
// Both live in spring-security-web, which IS on the classpath - so their absence is a
|
||||
// removal, not a missing optional module.
|
||||
assertThat(FilterOrderTable.isOnClasspath(channel)).isFalse();
|
||||
assertThat(FilterOrderTable.isOnClasspath(interceptor)).isFalse();
|
||||
assertThat(FilterOrderTable.isOnClasspath(AuthorizationFilter.class.getName())).isTrue();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user