diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 2a7f38e8a1..e2bb5ebb7f 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -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); diff --git a/config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java b/config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java index fe3a0b4c2f..a2ff87abd0 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java @@ -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 diff --git a/web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java b/web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java new file mode 100644 index 0000000000..7f96e6f0d9 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java @@ -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 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 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. + *

+ * 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)); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilterTests.java new file mode 100644 index 0000000000..9b0b5c64b3 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilterTests.java @@ -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'}"); + } + +}