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:
26
README.md
26
README.md
@@ -9,31 +9,39 @@ by that module's `scripts/run-all.sh`, never typed by hand.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [`context-propagation/`](context-propagation/README.md) | [Spring Security Context Propagation: The Complete Guide](https://ankurm.com/spring-security-context-propagation-complete-guide/) | Whether a `SecurityContext` survives `@Async`, executors, virtual threads, `StructuredTaskScope`, Reactor, schedulers and the servlet filter chain |
|
| [`context-propagation/`](context-propagation/README.md) | [Spring Security Context Propagation: The Complete Guide](https://ankurm.com/spring-security-context-propagation-complete-guide/) | Whether a `SecurityContext` survives `@Async`, executors, virtual threads, `StructuredTaskScope`, Reactor, schedulers and the servlet filter chain |
|
||||||
| [`method-security/`](method-security/README.md) | [Method Security in Spring Security 7: `@PreAuthorize`, `@PostAuthorize` and the Proxy Traps](https://ankurm.com/spring-security-7-method-security-proxy-traps/) | What the method-security annotations do, the full SpEL surface, and the cases where the check silently does not run |
|
| [`method-security/`](method-security/README.md) | [Method Security in Spring Security 7: `@PreAuthorize`, `@PostAuthorize` and the Proxy Traps](https://ankurm.com/spring-security-7-method-security-proxy-traps/) | What the method-security annotations do, the full SpEL surface, and the cases where the check silently does not run |
|
||||||
|
| [`filter-chain/`](filter-chain/README.md) | [The Spring Security Filter Chain Explained](https://ankurm.com/spring-security-filter-chain-explained/) | Every filter in the default chain and its order number, where a custom filter actually lands, and how to read the TRACE log |
|
||||||
|
|
||||||
The two are related more closely than they look. Method security reads the `Authentication`
|
The three are related more closely than they look. `filter-chain` is about how an
|
||||||
from `SecurityContextHolder` on the calling thread; the context-propagation module is about
|
`Authentication` gets into `SecurityContextHolder` in the first place and in what order;
|
||||||
getting it there. An `@Async` method carrying `@PreAuthorize` fails with
|
`context-propagation` is about whether it survives leaving the request thread; `method-security`
|
||||||
`AuthenticationCredentialsNotFoundException` for reasons that belong to the first module, not
|
reads it back on whatever thread it ends up on. An `@Async` method carrying `@PreAuthorize` fails
|
||||||
the second.
|
with `AuthenticationCredentialsNotFoundException` for reasons that belong to the second module,
|
||||||
|
not the third — and a custom authentication filter that never populated the context in the first
|
||||||
|
place fails the same way, for reasons that belong to the first.
|
||||||
|
|
||||||
## Common ground
|
## Common ground
|
||||||
|
|
||||||
Both modules target the same verified stack: **JDK 25** (Temurin 25.0.4.1+1),
|
All three modules target the same verified stack: **JDK 25** (Temurin 25.0.4.1+1),
|
||||||
**Spring Framework 7.0.9**, **Spring Security 7.1.1** — the versions Spring Boot **4.1.1**
|
**Spring Framework 7.0.9**, **Spring Security 7.1.1** — the versions Spring Boot **4.1.1**
|
||||||
manages. Versions were taken from `maven-metadata.xml` on Maven Central rather than from
|
manages. Versions were taken from `maven-metadata.xml` on Maven Central rather than from
|
||||||
release announcements.
|
release announcements.
|
||||||
|
|
||||||
`context-propagation` additionally needs `--enable-preview`, because `StructuredTaskScope` is
|
`context-propagation` additionally needs `--enable-preview`, because `StructuredTaskScope` is
|
||||||
still a preview API on JDK 25. `method-security` does not.
|
still a preview API on JDK 25. `method-security` does not. `filter-chain` is the only module
|
||||||
|
that is a real servlet application: it inherits `spring-boot-starter-parent` and runs on Tomcat,
|
||||||
|
because the thing it demonstrates only exists inside a servlet container.
|
||||||
|
|
||||||
## Running either module
|
## Running a module
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd method-security # or context-propagation
|
cd method-security # or context-propagation, or filter-chain
|
||||||
./scripts/run-all.sh # every demo plus the test suite, regenerating docs/output/
|
./scripts/run-all.sh # every demo plus the test suite, regenerating docs/output/
|
||||||
mvn test # just the assertions
|
mvn test # just the assertions
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`filter-chain` also has `./scripts/run.sh <profile>` and `./scripts/stop.sh`, because its
|
||||||
|
scenarios are a running web application rather than a `main()` method.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -71,6 +71,6 @@ nothing translates them and they surface raw.
|
|||||||
received the context, method security does not fail open; it throws
|
received the context, method security does not fail open; it throws
|
||||||
`AuthenticationCredentialsNotFoundException`. Getting the context onto that thread is a
|
`AuthenticationCredentialsNotFoundException`. Getting the context onto that thread is a
|
||||||
separate topic with its own module in this repository — see
|
separate topic with its own module in this repository — see
|
||||||
[`../context-propagation/`](../context-propagation/README.md).
|
[`../context-propagation/`](../../context-propagation/README.md).
|
||||||
|
|
||||||
[← chapter index](README.md) · [next: the SpEL reference →](02-spel-reference.md)
|
[← chapter index](README.md) · [next: the SpEL reference →](02-spel-reference.md)
|
||||||
|
|||||||
Reference in New Issue
Block a user