Serve static content (css, js) for reactive default UIs from DefaultResourcesWebFilter

This commit is contained in:
Daniel Garnier-Moiroux 2024-08-30 16:09:36 +02:00 committed by Rob Winch
parent 11616a1d78
commit 45d53973ab
4 changed files with 222 additions and 0 deletions

View File

@ -194,6 +194,7 @@ import org.springframework.security.web.server.savedrequest.ServerRequestCache;
import org.springframework.security.web.server.savedrequest.ServerRequestCacheWebFilter;
import org.springframework.security.web.server.savedrequest.WebSessionServerRequestCache;
import org.springframework.security.web.server.transport.HttpsRedirectWebFilter;
import org.springframework.security.web.server.ui.DefaultResourcesWebFilter;
import org.springframework.security.web.server.ui.LoginPageGeneratingWebFilter;
import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter;
import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher;
@ -2974,6 +2975,7 @@ public class ServerHttpSecurity {
}
if (loginPage != null) {
http.addFilterAt(loginPage, SecurityWebFiltersOrder.LOGIN_PAGE_GENERATING);
http.addFilterBefore(DefaultResourcesWebFilter.css(), SecurityWebFiltersOrder.LOGIN_PAGE_GENERATING);
if (http.logout != null) {
http.addFilterAt(new LogoutPageGeneratingWebFilter(),
SecurityWebFiltersOrder.LOGOUT_PAGE_GENERATING);

View File

@ -23,6 +23,7 @@ import java.util.Optional;
import java.util.stream.Collectors;
import org.apache.http.HttpHeaders;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -738,6 +739,40 @@ public class ServerHttpSecurityTests {
.isSameAs(authorizationRedirectStrategy);
}
@Test
void resourcesWhenLoginPageConfiguredThenServesCss() {
this.http.formLogin(withDefaults());
this.http.authenticationManager(this.authenticationManager);
WebTestClient client = WebTestClientBuilder
.bindToControllerAndWebFilters(NotFoundController.class, this.http.build())
.build();
client.get()
.uri("/default-ui.css")
.exchange()
.expectStatus()
.isOk()
.expectBody(String.class)
.value(Matchers.containsString("body {"));
}
@Test
void resourcesWhenLoginPageNotConfiguredThenDoesNotServeCss() {
this.http.httpBasic(withDefaults());
this.http.authenticationManager(this.authenticationManager);
WebTestClient client = WebTestClientBuilder
.bindToControllerAndWebFilters(NotFoundController.class, this.http.build())
.build();
client.get()
.uri("/default-ui.css")
.exchange()
.expectStatus()
.isNotFound()
.expectBody(String.class)
.isEqualTo(null);
}
private boolean isX509Filter(WebFilter filter) {
try {
Object converter = ReflectionTestUtils.getField(filter, "authenticationConverter");
@ -775,6 +810,13 @@ public class ServerHttpSecurityTests {
}
@RestController
private static class NotFoundController {
// Empty controller, makes WebTestClient return HTTP 404
}
private static class TestWebFilter implements WebFilter {
@Override

View File

@ -0,0 +1,101 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.ui;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import reactor.core.publisher.Mono;
import org.springframework.core.ResolvableType;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ResourceHttpMessageWriter;
import org.springframework.security.web.authentication.ui.DefaultResourcesFilter;
import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
/**
* Serve common static assets used in default UIs, such as CSS or Javascript files. For
* internal use only.
*
* @author Daniel Garnier-Moiroux
* @since 6.4
*/
public final class DefaultResourcesWebFilter implements WebFilter {
private final ServerWebExchangeMatcher matcher;
private final ClassPathResource resource;
private final MediaType mediaType;
private DefaultResourcesWebFilter(ServerWebExchangeMatcher matcher, ClassPathResource resource,
MediaType mediaType) {
Assert.isTrue(resource.exists(), "classpath resource must exist");
this.matcher = matcher;
this.resource = resource;
this.mediaType = mediaType;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
return this.matcher.matches(exchange)
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
.switchIfEmpty(chain.filter(exchange).then(Mono.empty()))
.flatMap((matchResult) -> sendContent(exchange));
}
private Mono<Void> sendContent(ServerWebExchange exchange) {
exchange.getResponse().setStatusCode(HttpStatus.OK);
ResourceHttpMessageWriter writer = new ResourceHttpMessageWriter();
return writer.write(Mono.just(this.resource), ResolvableType.forClass(Resource.class),
ResolvableType.forClass(Resource.class), this.mediaType, exchange.getRequest(), exchange.getResponse(),
Collections.emptyMap());
}
@Override
public String toString() {
return "%s{matcher=%s, resource='%s'}".formatted(getClass().getSimpleName(), this.matcher,
this.resource.getPath());
}
/**
* Create an instance of {@link DefaultResourcesWebFilter} serving Spring Security's
* default CSS stylesheet.
* <p>
* The created {@link DefaultResourcesFilter} matches requests
* {@code HTTP GET /default-ui.css}, and returns the default stylesheet at
* {@code org/springframework/security/default-ui.css} with content-type
* {@code text/css;charset=UTF-8}.
* @return -
*/
public static DefaultResourcesWebFilter css() {
return new DefaultResourcesWebFilter(
new PathPatternParserServerWebExchangeMatcher("/default-ui.css", HttpMethod.GET),
new ClassPathResource("org/springframework/security/default-ui.css"),
new MediaType("text", "css", StandardCharsets.UTF_8));
}
}

View File

@ -0,0 +1,77 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.ui;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import org.springframework.web.server.WebHandler;
import org.springframework.web.server.handler.DefaultWebFilterChain;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Daniel Garnier-Moiroux
* @since 6.4
*/
class DefaultResourcesWebFilterTests {
private final WebHandler notFoundHandler = (exchange) -> {
exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);
return Mono.empty();
};
private final DefaultResourcesWebFilter filter = DefaultResourcesWebFilter.css();
@Test
void filterWhenPathMatchesThenRenders() {
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/default-ui.css"));
WebFilterChain filterChain = new DefaultWebFilterChain(this.notFoundHandler, List.of(this.filter));
filterChain.filter(exchange).block();
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(exchange.getResponse().getHeaders().getContentType())
.isEqualTo(new MediaType("text", "css", StandardCharsets.UTF_8));
assertThat(exchange.getResponse().getBodyAsString().block()).contains("body {");
}
@Test
void filterWhenPathDoesNotMatchThenCallsThrough() {
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/does-not-match"));
WebFilterChain filterChain = new DefaultWebFilterChain(this.notFoundHandler, List.of(this.filter));
filterChain.filter(exchange).block();
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void toStringPrintsPathAndResource() {
assertThat(this.filter.toString()).isEqualTo(
"DefaultResourcesWebFilter{matcher=PathMatcherServerWebExchangeMatcher{pattern='/default-ui.css', method=GET}, resource='org/springframework/security/default-ui.css'}");
}
}