From 8d7f745191a67e21b31297ae6475eb19c8ea5dde Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 8 Aug 2024 09:06:25 +0200 Subject: [PATCH 1/3] Render default UIs using lightweight templates --- .../DefaultLoginPageConfigurerTests.java | 454 +++++++++--------- .../FormLoginBeanDefinitionParserTests.java | 366 +++++++------- .../ui/DefaultLoginPageGeneratingFilter.java | 280 +++++++---- .../ui/DefaultLogoutPageGeneratingFilter.java | 65 +-- .../web/authentication/ui/HtmlTemplates.java | 107 +++++ ...DefaultLoginPageGeneratingFilterTests.java | 197 +++++++- ...efaultLogoutPageGeneratingFilterTests.java | 152 ++++++ .../authentication/ui/HtmlTemplatesTests.java | 147 ++++++ .../LogoutPageGeneratingWebFilterTests.java | 8 + 9 files changed, 1258 insertions(+), 518 deletions(-) create mode 100644 web/src/main/java/org/springframework/security/web/authentication/ui/HtmlTemplates.java create mode 100644 web/src/test/java/org/springframework/security/web/authentication/ui/HtmlTemplatesTests.java diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurerTests.java index 0f2b64e7241..d49ade6696d 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurerTests.java @@ -67,140 +67,142 @@ @ExtendWith(SpringTestContextExtension.class) public class DefaultLoginPageConfigurerTests { - //@formatter:off - public static final String EXPECTED_HTML_HEAD = " \n" - + " \n" - + " \n" - + " \n" - + " \n" - + " Please sign in\n" - + " \n" - + " \n"; - //@formatter:on + public static final String EXPECTED_HTML_HEAD = """ + + + + + + + + Please sign in + + + """; public final SpringTestContext spring = new SpringTestContext(this); @@ -222,26 +224,32 @@ public void loginPageThenDefaultLoginPageIsRendered() throws Exception { this.mvc.perform(get("/login").sessionAttr(csrfAttributeName, csrfToken)) .andExpect((result) -> { CsrfToken token = (CsrfToken) result.getRequest().getAttribute(CsrfToken.class.getName()); - assertThat(result.getResponse().getContentAsString()).isEqualTo("\n" - + "\n" - + EXPECTED_HTML_HEAD - + " \n" - + "
\n" - + "
\n" - + "

Please sign in

\n" - + "

\n" - + " \n" - + " \n" - + "

\n" - + "

\n" - + " \n" - + " \n" - + "

\n" - + "\n" - + " \n" - + "
\n" - + "
\n" - + ""); + assertThat(result.getResponse().getContentAsString()).isEqualTo( + EXPECTED_HTML_HEAD + + """ + +
+ + + + +
+ + """.formatted(token.getToken())); }); // @formatter:on } @@ -263,25 +271,32 @@ public void loginPageWhenErrorThenDefaultLoginPageWithError() throws Exception { .sessionAttr(csrfAttributeName, csrfToken)) .andExpect((result) -> { CsrfToken token = (CsrfToken) result.getRequest().getAttribute(CsrfToken.class.getName()); - assertThat(result.getResponse().getContentAsString()).isEqualTo("\n" - + "\n" - + EXPECTED_HTML_HEAD - + " \n" - + "
\n" - + "
\n" - + "

Please sign in

\n" - + "
Bad credentials

\n" - + " \n" - + " \n" - + "

\n" + "

\n" - + " \n" - + " \n" - + "

\n" - + "\n" - + " \n" - + "
\n" - + "
\n" - + ""); + assertThat(result.getResponse().getContentAsString()).isEqualTo( + EXPECTED_HTML_HEAD + + """ + +
+ + + + +
+ + """.formatted(token.getToken())); }); // @formatter:on } @@ -307,26 +322,32 @@ public void loginPageWhenLoggedOutThenDefaultLoginPageWithLogoutMessage() throws this.mvc.perform(get("/login?logout").sessionAttr(csrfAttributeName, csrfToken)) .andExpect((result) -> { CsrfToken token = (CsrfToken) result.getRequest().getAttribute(CsrfToken.class.getName()); - assertThat(result.getResponse().getContentAsString()).isEqualTo("\n" - + "\n" - + EXPECTED_HTML_HEAD - + " \n" - + "
\n" - + "
\n" - + "

Please sign in

\n" - + "
You have been signed out

\n" - + " \n" - + " \n" - + "

\n" - + "

\n" - + " \n" - + " \n" - + "

\n" - + "\n" - + " \n" - + "
\n" - + "
\n" - + ""); + assertThat(result.getResponse().getContentAsString()).isEqualTo( + EXPECTED_HTML_HEAD + + """ + +
+ + + + +
+ + """.formatted(token.getToken())); }); // @formatter:on } @@ -352,27 +373,32 @@ public void loginPageWhenRememberConfigureThenDefaultLoginPageWithRememberMeChec this.mvc.perform(get("/login").sessionAttr(csrfAttributeName, csrfToken)) .andExpect((result) -> { CsrfToken token = (CsrfToken) result.getRequest().getAttribute(CsrfToken.class.getName()); - assertThat(result.getResponse().getContentAsString()).isEqualTo("\n" - + "\n" - + EXPECTED_HTML_HEAD - + " \n" - + "
\n" - + "
\n" - + "

Please sign in

\n" - + "

\n" - + " \n" - + " \n" - + "

\n" - + "

\n" - + " \n" - + " \n" - + "

\n" - + "

Remember me on this computer.

\n" - + "\n" - + " \n" - + "
\n" - + "
\n" - + ""); + assertThat(result.getResponse().getContentAsString()).isEqualTo( + EXPECTED_HTML_HEAD + + """ + +
+ + + + +
+ + """.formatted(token.getToken())); }); // @formatter:on } diff --git a/config/src/test/java/org/springframework/security/config/http/FormLoginBeanDefinitionParserTests.java b/config/src/test/java/org/springframework/security/config/http/FormLoginBeanDefinitionParserTests.java index 1d16c26571c..661d20c2352 100644 --- a/config/src/test/java/org/springframework/security/config/http/FormLoginBeanDefinitionParserTests.java +++ b/config/src/test/java/org/springframework/security/config/http/FormLoginBeanDefinitionParserTests.java @@ -45,140 +45,142 @@ public class FormLoginBeanDefinitionParserTests { private static final String CONFIG_LOCATION_PREFIX = "classpath:org/springframework/security/config/http/FormLoginBeanDefinitionParserTests"; - //@formatter:off - public static final String EXPECTED_HTML_HEAD = " \n" - + " \n" - + " \n" - + " \n" - + " \n" - + " Please sign in\n" - + " \n" - + " \n"; - //@formatter:on + public static final String EXPECTED_HTML_HEAD = """ + + + + + + + + Please sign in + + + """; public final SpringTestContext spring = new SpringTestContext(this); @@ -188,27 +190,30 @@ public class FormLoginBeanDefinitionParserTests { @Test public void getLoginWhenAutoConfigThenShowsDefaultLoginPage() throws Exception { this.spring.configLocations(this.xml("Simple")).autowire(); - // @formatter:off - String expectedContent = "\n" - + "\n" - + EXPECTED_HTML_HEAD - + " \n" - + "
\n" - + "
\n" - + "

Please sign in

\n" - + "

\n" - + " \n" - + " \n" - + "

\n" - + "

\n" - + " \n" - + " \n" - + "

\n" - + " \n" - + "
\n" - + "
\n" - + ""; - // @formatter:on + String expectedContent = EXPECTED_HTML_HEAD + """ + +
+ + + + +
+ + """; this.mvc.perform(get("/login")).andExpect(content().string(expectedContent)); } @@ -221,31 +226,32 @@ public void getLogoutWhenAutoConfigThenShowsDefaultLogoutPage() throws Exception @Test public void getLoginWhenConfiguredWithCustomAttributesThenLoginPageReflects() throws Exception { this.spring.configLocations(this.xml("WithCustomAttributes")).autowire(); - // @formatter:off - String expectedContent = "\n" - + "\n" - + EXPECTED_HTML_HEAD - + " \n" - + "
\n" - + "
\n" - + "

Please sign in

\n" - + "

\n" - + " \n" - + " \n" - + "

\n" - + "

\n" - + " \n" - + " \n" - + "

\n" - + " \n" - + "
\n" - + "
\n" - + ""; - this.mvc.perform(get("/login")) - .andExpect(content().string(expectedContent)); - this.mvc.perform(get("/logout")) - .andExpect(status().is3xxRedirection()); - // @formatter:on + String expectedContent = EXPECTED_HTML_HEAD + """ + +
+ + + + +
+ + """; + this.mvc.perform(get("/login")).andExpect(content().string(expectedContent)); + this.mvc.perform(get("/logout")).andExpect(status().is3xxRedirection()); } @Test diff --git a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java index 042fc41d262..7373443dce9 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java +++ b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java @@ -21,6 +21,7 @@ import java.util.Collections; import java.util.Map; import java.util.function.Function; +import java.util.stream.Collectors; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -38,7 +39,6 @@ import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.web.filter.GenericFilterBean; -import org.springframework.web.util.HtmlUtils; /** * For internal use with namespace configuration in the case where a user doesn't @@ -205,87 +205,106 @@ private void doFilter(HttpServletRequest request, HttpServletResponse response, private String generateLoginPageHtml(HttpServletRequest request, boolean loginError, boolean logoutSuccess) { String errorMsg = loginError ? getLoginErrorMessage(request) : "Invalid credentials"; String contextPath = request.getContextPath(); - StringBuilder sb = new StringBuilder(); - sb.append("\n"); - sb.append("\n"); - sb.append(" \n"); - sb.append(" \n"); - sb.append(" \n"); - sb.append(" \n"); - sb.append(" \n"); - sb.append(" Please sign in\n"); - sb.append(CssUtils.getCssStyleBlock().indent(4)); - sb.append(" \n"); - sb.append(" \n"); - sb.append("
\n"); - if (this.formLoginEnabled) { - sb.append("
\n"); - sb.append("

Please sign in

\n"); - sb.append(createError(loginError, errorMsg) + createLogoutSuccess(logoutSuccess) + "

\n"); - sb.append(" \n"); - sb.append(" \n"); - sb.append("

\n"); - sb.append("

\n"); - sb.append(" \n"); - sb.append(" \n"); - sb.append("

\n"); - sb.append(createRememberMe(this.rememberMeParameter) + renderHiddenInputs(request)); - sb.append(" \n"); - sb.append("
\n"); + + return HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE) + .withRawHtml("cssStyle", CssUtils.getCssStyleBlock().indent(4)) + .withRawHtml("formLogin", renderFormLogin(request, loginError, logoutSuccess, contextPath, errorMsg)) + .withRawHtml("oneTimeTokenLogin", + renderOneTimeTokenLogin(request, loginError, logoutSuccess, contextPath, errorMsg)) + .withRawHtml("oauth2Login", renderOAuth2Login(loginError, logoutSuccess, errorMsg, contextPath)) + .withRawHtml("saml2Login", renderSaml2Login(loginError, logoutSuccess, errorMsg, contextPath)) + .render(); + } + + private String renderFormLogin(HttpServletRequest request, boolean loginError, boolean logoutSuccess, + String contextPath, String errorMsg) { + if (!this.formLoginEnabled) { + return ""; } - if (this.oneTimeTokenEnabled) { - sb.append("
\n"); - sb.append("

Request a One-Time Token

\n"); - sb.append(createError(loginError, errorMsg) + createLogoutSuccess(logoutSuccess) + "

\n"); - sb.append(" \n"); - sb.append( - " \n"); - sb.append("

\n"); - sb.append(renderHiddenInputs(request)); - sb.append(" \n"); - sb.append("
\n"); + + String hiddenInputs = this.resolveHiddenInputs.apply(request) + .entrySet() + .stream() + .map((inputKeyValue) -> renderHiddenInput(inputKeyValue.getKey(), inputKeyValue.getValue())) + .collect(Collectors.joining("\n")); + + return HtmlTemplates.fromTemplate(LOGIN_FORM_TEMPLATE) + .withValue("loginUrl", contextPath + this.authenticationUrl) + .withRawHtml("errorMessage", renderError(loginError, errorMsg)) + .withRawHtml("logoutMessage", renderSuccess(logoutSuccess)) + .withValue("usernameParameter", this.usernameParameter) + .withValue("passwordParameter", this.passwordParameter) + .withRawHtml("rememberMeInput", renderRememberMe(this.rememberMeParameter)) + .withRawHtml("hiddenInputs", hiddenInputs) + .render(); + } + + private String renderOneTimeTokenLogin(HttpServletRequest request, boolean loginError, boolean logoutSuccess, + String contextPath, String errorMsg) { + if (!this.oneTimeTokenEnabled) { + return ""; } - if (this.oauth2LoginEnabled) { - sb.append("

Login with OAuth 2.0

"); - sb.append(createError(loginError, errorMsg)); - sb.append(createLogoutSuccess(logoutSuccess)); - sb.append("\n"); - for (Map.Entry clientAuthenticationUrlToClientName : this.oauth2AuthenticationUrlToClientName - .entrySet()) { - sb.append(" \n"); - } - sb.append("
"); - String url = clientAuthenticationUrlToClientName.getKey(); - sb.append(""); - String clientName = HtmlUtils.htmlEscape(clientAuthenticationUrlToClientName.getValue()); - sb.append(clientName); - sb.append(""); - sb.append("
\n"); + + String hiddenInputs = this.resolveHiddenInputs.apply(request) + .entrySet() + .stream() + .map((inputKeyValue) -> renderHiddenInput(inputKeyValue.getKey(), inputKeyValue.getValue())) + .collect(Collectors.joining("\n")); + + return HtmlTemplates.fromTemplate(ONE_TIME_TEMPLATE) + .withValue("generateOneTimeTokenUrl", contextPath + this.generateOneTimeTokenUrl) + .withRawHtml("errorMessage", renderError(loginError, errorMsg)) + .withRawHtml("logoutMessage", renderSuccess(logoutSuccess)) + .withRawHtml("hiddenInputs", hiddenInputs) + .render(); + } + + private String renderOAuth2Login(boolean loginError, boolean logoutSuccess, String errorMsg, String contextPath) { + if (!this.oauth2LoginEnabled) { + return ""; } - if (this.saml2LoginEnabled) { - sb.append("

Login with SAML 2.0

"); - sb.append(createError(loginError, errorMsg)); - sb.append(createLogoutSuccess(logoutSuccess)); - sb.append("\n"); - for (Map.Entry relyingPartyUrlToName : this.saml2AuthenticationUrlToProviderName - .entrySet()) { - sb.append(" \n"); - } - sb.append("
"); - String url = relyingPartyUrlToName.getKey(); - sb.append(""); - String partyName = HtmlUtils.htmlEscape(relyingPartyUrlToName.getValue()); - sb.append(partyName); - sb.append(""); - sb.append("
\n"); + + String oauth2Rows = this.oauth2AuthenticationUrlToClientName.entrySet() + .stream() + .map((urlToName) -> renderOAuth2Row(contextPath, urlToName.getKey(), urlToName.getValue())) + .collect(Collectors.joining("\n")); + + return HtmlTemplates.fromTemplate(OAUTH2_LOGIN_TEMPLATE) + .withRawHtml("errorMessage", renderError(loginError, errorMsg)) + .withRawHtml("logoutMessage", renderSuccess(logoutSuccess)) + .withRawHtml("oauth2Rows", oauth2Rows) + .render(); + } + + private static String renderOAuth2Row(String contextPath, String url, String clientName) { + return HtmlTemplates.fromTemplate(OAUTH2_ROW_TEMPLATE) + .withValue("url", contextPath + url) + .withValue("clientName", clientName) + .render(); + } + + private String renderSaml2Login(boolean loginError, boolean logoutSuccess, String errorMsg, String contextPath) { + if (!this.saml2LoginEnabled) { + return ""; } - sb.append("
\n"); - sb.append(""); - return sb.toString(); + + String samlRows = this.saml2AuthenticationUrlToProviderName.entrySet() + .stream() + .map((urlToName) -> renderSaml2Row(contextPath, urlToName.getKey(), urlToName.getValue())) + .collect(Collectors.joining("\n")); + + return HtmlTemplates.fromTemplate(SAML_LOGIN_TEMPLATE) + .withRawHtml("errorMessage", renderError(loginError, errorMsg)) + .withRawHtml("logoutMessage", renderSuccess(logoutSuccess)) + .withRawHtml("samlRows", samlRows) + .render(); + } + + private static String renderSaml2Row(String contextPath, String url, String clientName) { + return HtmlTemplates.fromTemplate(SAML_ROW_TEMPLATE) + .withValue("url", contextPath + url) + .withValue("clientName", clientName) + .render(); } private String getLoginErrorMessage(HttpServletRequest request) { @@ -303,23 +322,21 @@ private String getLoginErrorMessage(HttpServletRequest request) { return exception.getMessage(); } - private String renderHiddenInputs(HttpServletRequest request) { - StringBuilder sb = new StringBuilder(); - for (Map.Entry input : this.resolveHiddenInputs.apply(request).entrySet()) { - sb.append("\n"); - } - return sb.toString(); + private String renderHiddenInput(String name, String value) { + return HtmlTemplates.fromTemplate(HIDDEN_HTML_INPUT_TEMPLATE) + .withValue("name", name) + .withValue("value", value) + .render(); } - private String createRememberMe(String paramName) { + private String renderRememberMe(String paramName) { if (paramName == null) { return ""; } - return "

Remember me on this computer.

\n"; + return HtmlTemplates + .fromTemplate("

Remember me on this computer.

") + .withValue("paramName", paramName) + .render(); } private boolean isLogoutSuccess(HttpServletRequest request) { @@ -334,14 +351,14 @@ private boolean isErrorPage(HttpServletRequest request) { return matches(request, this.failureUrl); } - private String createError(boolean isError, String message) { + private String renderError(boolean isError, String message) { if (!isError) { return ""; } - return "
" + HtmlUtils.htmlEscape(message) + "
"; + return HtmlTemplates.fromTemplate(ALERT_TEMPLATE).withValue("message", message).render(); } - private String createLogoutSuccess(boolean isLogoutSuccess) { + private String renderSuccess(boolean isLogoutSuccess) { if (!isLogoutSuccess) { return ""; } @@ -367,4 +384,81 @@ private boolean matches(HttpServletRequest request, String url) { return uri.equals(request.getContextPath() + url); } + private static final String LOGIN_PAGE_TEMPLATE = """ + + + + + + + + Please sign in + {{cssStyle}} + + +
+ {{formLogin}} + {{oneTimeTokenLogin}} + {{oauth2Login}} + {{saml2Login}} +
+ + """; + + private static final String LOGIN_FORM_TEMPLATE = """ + """; + + private static final String HIDDEN_HTML_INPUT_TEMPLATE = """ + + """; + + private static final String ALERT_TEMPLATE = """ + """; + + private static final String OAUTH2_LOGIN_TEMPLATE = """ +

Login with OAuth 2.0

+ {{errorMessage}}{{logoutMessage}} + + {{oauth2Rows}} +
"""; + + private static final String OAUTH2_ROW_TEMPLATE = """ + {{clientName}}"""; + + private static final String SAML_LOGIN_TEMPLATE = """ +

Login with SAML 2.0

+ {{errorMessage}}{{logoutMessage}} + + {{samlRows}} +
"""; + + private static final String SAML_ROW_TEMPLATE = OAUTH2_ROW_TEMPLATE; + + private static final String ONE_TIME_TEMPLATE = """ + + """; + } diff --git a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLogoutPageGeneratingFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLogoutPageGeneratingFilter.java index 9c38b8cb5e5..d5dbf85f2d0 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLogoutPageGeneratingFilter.java +++ b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLogoutPageGeneratingFilter.java @@ -61,30 +61,13 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } private void renderLogout(HttpServletRequest request, HttpServletResponse response) throws IOException { - StringBuilder sb = new StringBuilder(); - sb.append("\n"); - sb.append("\n"); - sb.append(" \n"); - sb.append(" \n"); - sb.append(" \n"); - sb.append(" \n"); - sb.append(" \n"); - sb.append(" Confirm Log Out?\n"); - sb.append(CssUtils.getCssStyleBlock().indent(4)); - sb.append(" \n"); - sb.append(" \n"); - sb.append("
\n"); - sb.append("
\n"); - sb.append("

Are you sure you want to log out?

\n"); - sb.append(renderHiddenInputs(request)); - sb.append(" \n"); - sb.append("
\n"); - sb.append("
\n"); - sb.append(" \n"); - sb.append(""); + String renderedPage = HtmlTemplates.fromTemplate(LOGOUT_PAGE_TEMPLATE) + .withRawHtml("cssStyle", CssUtils.getCssStyleBlock().indent(4)) + .withValue("contextPath", request.getContextPath()) + .withRawHtml("hiddenInputs", renderHiddenInputs(request).indent(8)) + .render(); response.setContentType("text/html;charset=UTF-8"); - response.getWriter().write(sb.toString()); + response.getWriter().write(renderedPage); } /** @@ -101,13 +84,39 @@ public void setResolveHiddenInputs(Function input : this.resolveHiddenInputs.apply(request).entrySet()) { - sb.append("\n"); + String inputElement = HtmlTemplates.fromTemplate(HIDDEN_HTML_INPUT_TEMPLATE) + .withValue("name", input.getKey()) + .withValue("value", input.getValue()) + .render(); + sb.append(inputElement); } return sb.toString(); } + private static final String LOGOUT_PAGE_TEMPLATE = """ + + + + + + + + Confirm Log Out? + {{cssStyle}} + + +
+
+

Are you sure you want to log out?

+ {{hiddenInputs}} + +
+
+ + """; + + private static final String HIDDEN_HTML_INPUT_TEMPLATE = """ + + """; + } diff --git a/web/src/main/java/org/springframework/security/web/authentication/ui/HtmlTemplates.java b/web/src/main/java/org/springframework/security/web/authentication/ui/HtmlTemplates.java new file mode 100644 index 00000000000..6d897ebf0f5 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ui/HtmlTemplates.java @@ -0,0 +1,107 @@ +/* + * 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.authentication.ui; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.springframework.util.StringUtils; +import org.springframework.web.util.HtmlUtils; + +/** + * Render HTML templates using string substitution. Intended for internal use. Variables + * can be templated using double curly-braces: {@code {{name}}}. + * + * @author Daniel Garnier-Moiroux + * @since 6.4 + */ +final class HtmlTemplates { + + private HtmlTemplates() { + } + + static Builder fromTemplate(String template) { + return new Builder(template); + } + + static final class Builder { + + private final String template; + + private final Map values = new HashMap<>(); + + private Builder(String template) { + this.template = template; + } + + /** + * HTML-escape, and inject value {@code value} in every {@code {{key}}} + * placeholder. + * @param key the placeholder name + * @param value the value to inject + * @return this instance for further templating + */ + Builder withValue(String key, String value) { + this.values.put(key, HtmlUtils.htmlEscape(value)); + return this; + } + + /** + * Inject value {@code value} in every {@code {{key}}} placeholder without + * HTML-escaping. Useful for injecting "sub-templates". + * @param key the placeholder name + * @param value the value to inject + * @return this instance for further templating + */ + Builder withRawHtml(String key, String value) { + if (!value.isEmpty() && value.charAt(value.length() - 1) == '\n') { + value = value.substring(0, value.length() - 1); + } + this.values.put(key, value); + return this; + } + + /** + * Render the template. All placeholders MUST have a corresponding value. If a + * placeholder does not have a corresponding value, throws + * {@link IllegalStateException}. + * @return the rendered template + */ + String render() { + String template = this.template; + for (String key : this.values.keySet()) { + String pattern = Pattern.quote("{{" + key + "}}"); + template = template.replaceAll(pattern, this.values.get(key)); + } + + String unusedPlaceholders = Pattern.compile("\\{\\{([a-zA-Z0-9]+)}}") + .matcher(template) + .results() + .map((result) -> result.group(1)) + .collect(Collectors.joining(", ")); + if (StringUtils.hasLength(unusedPlaceholders)) { + throw new IllegalStateException("Unused placeholders in template: [%s]".formatted(unusedPlaceholders)); + } + + return template; + } + + } + +} diff --git a/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java b/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java index 95277098787..e9c21163f9a 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java @@ -16,10 +16,12 @@ package org.springframework.security.web.authentication; +import java.io.IOException; import java.util.Collections; import java.util.Locale; import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; import org.junit.jupiter.api.Test; import org.springframework.context.support.MessageSourceAccessor; @@ -195,15 +197,204 @@ public void generateWhenOneTimeTokenLoginThenOttForm() throws Exception { filter.doFilter(new MockHttpServletRequest("GET", "/login"), response, this.chain); assertThat(response.getContentAsString()).contains("Request a One-Time Token"); assertThat(response.getContentAsString()).contains(""" - """); } + @Test + void generatesThenRenders() throws ServletException, IOException { + DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter( + new UsernamePasswordAuthenticationFilter()); + filter.setLoginPageUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL); + filter.setSaml2LoginEnabled(true); + String clientName = "Google < > \" \' &"; + filter.setSaml2AuthenticationUrlToProviderName(Collections.singletonMap("/saml/sso/google", clientName)); + filter.setOauth2LoginEnabled(true); + clientName = "Google < > \" \' &"; + filter.setOauth2AuthenticationUrlToClientName( + Collections.singletonMap("/oauth2/authorization/google", clientName)); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/login"); + request.setQueryString("error"); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.getSession() + .setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION, new BadCredentialsException("Bad credentials")); + filter.doFilter(request, response, this.chain); + assertThat(response.getContentAsString()).isEqualTo(""" + + + + + + + + Please sign in + + + +
+ + +

Login with OAuth 2.0

+ + + +
Google < > " ' &
+

Login with SAML 2.0

+ + + +
Google < > " ' &
+
+ + """); + } + } diff --git a/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultLogoutPageGeneratingFilterTests.java b/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultLogoutPageGeneratingFilterTests.java index 796ef2f0730..85f76e6314c 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultLogoutPageGeneratingFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultLogoutPageGeneratingFilterTests.java @@ -59,4 +59,156 @@ public void doFilterWhenRequestContextThenActionContainsRequestContext() throws .andExpect(content().string(containsString("action=\"/context/logout\""))); } + @Test + void doFilterWhenRequestContextAndHiddenInputsSetThenRendered() throws Exception { + this.filter.setResolveHiddenInputs((r) -> Collections.singletonMap("_csrf", "csrf-token-1")); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new Object()).addFilters(this.filter).build(); + + mockMvc.perform(get("/context/logout").contextPath("/context")).andExpect(content().string(""" + + + + + + + + Confirm Log Out? + + + +
+
+

Are you sure you want to log out?

+ + +
+
+ + """)); + } + } diff --git a/web/src/test/java/org/springframework/security/web/authentication/ui/HtmlTemplatesTests.java b/web/src/test/java/org/springframework/security/web/authentication/ui/HtmlTemplatesTests.java new file mode 100644 index 00000000000..22d472af09f --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/ui/HtmlTemplatesTests.java @@ -0,0 +1,147 @@ +/* + * 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.authentication.ui; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * @author Daniel Garnier-Moiroux + * @since 6.4 + */ +class HtmlTemplatesTests { + + @Test + void processTemplateWhenNoVariablesThenRendersTemplate() { + String template = """ +
    +
  • Lorem ipsum dolor sit amet
  • +
  • consectetur adipiscing elit
  • +
  • sed do eiusmod tempor incididunt ut labore
  • +
  • et dolore magna aliqua
  • +
+ """; + + assertThat(HtmlTemplates.fromTemplate(template).render()).isEqualTo(template); + } + + @Test + void renderWhenVariablesThenRendersTemplate() { + String template = """ +
    +
  • {{one}}
  • +
  • {{two}}
  • +
+ """; + + String renderedTemplate = HtmlTemplates.fromTemplate(template) + .withValue("one", "Lorem ipsum dolor sit amet") + .withValue("two", "consectetur adipiscing elit") + .render(); + + assertThat(renderedTemplate).isEqualTo(""" +
    +
  • Lorem ipsum dolor sit amet
  • +
  • consectetur adipiscing elit
  • +
+ """); + } + + @Test + void renderWhenVariablesThenEscapedAndRender() { + String template = "

{{content}}

"; + + String renderedTemplate = HtmlTemplates.fromTemplate(template) + .withValue("content", "The tag is very common in HTML.") + .render(); + + assertThat(renderedTemplate).isEqualTo("

The <a> tag is very common in HTML.

"); + } + + @Test + void renderWhenRawHtmlVariablesThenRendersTemplate() { + String template = """ +

+ The {{title}} is a placeholder text used in print. +

+ """; + + String renderedTemplate = HtmlTemplates.fromTemplate(template) + .withRawHtml("title", "Lorem Ipsum") + .render(); + + assertThat(renderedTemplate).isEqualTo(""" +

+ The Lorem Ipsum is a placeholder text used in print. +

+ """); + } + + @Test + void renderWhenRawHtmlVariablesThenTrimsTrailingNewline() { + String template = """ +
    + {{content}} +
+ """; + + String renderedTemplate = HtmlTemplates.fromTemplate(template) + .withRawHtml("content", "
  • Lorem ipsum dolor sit amet
  • ".indent(2)) + .render(); + + assertThat(renderedTemplate).isEqualTo(""" +
      +
    • Lorem ipsum dolor sit amet
    • +
    + """); + } + + @Test + void renderWhenEmptyVariablesThenRender() { + String template = """ +
  • One: {{one}}
  • + {{two}} + """; + + String renderedTemplate = HtmlTemplates.fromTemplate(template) + .withValue("one", "") + .withRawHtml("two", "") + .render(); + + assertThat(renderedTemplate).isEqualTo(""" +
  • One:
  • + + """); + } + + @Test + void renderWhenMissingVariablesThenThrows() { + String template = """ +
  • One: {{one}}
  • +
  • Two: {{two}}
  • + {{three}} + """; + + HtmlTemplates.Builder templateBuilder = HtmlTemplates.fromTemplate(template) + .withValue("one", "Lorem ipsum dolor sit amet"); + assertThatExceptionOfType(IllegalStateException.class).isThrownBy(templateBuilder::render) + .withMessage("Unused placeholders in template: [two, three]"); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/server/ui/LogoutPageGeneratingWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/ui/LogoutPageGeneratingWebFilterTests.java index bb2feb3e98d..a3b5b29ab2a 100644 --- a/web/src/test/java/org/springframework/security/web/server/ui/LogoutPageGeneratingWebFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/ui/LogoutPageGeneratingWebFilterTests.java @@ -43,4 +43,12 @@ public void filterWhenLogoutWithNoContextPathThenActionDoesNotContainsContextPat assertThat(exchange.getResponse().getBodyAsString().block()).contains("action=\"/logout\""); } + @Test + void filterThenRendersPage() { + LogoutPageGeneratingWebFilter filter = new LogoutPageGeneratingWebFilter(); + MockServerWebExchange exchange = MockServerWebExchange + .from(MockServerHttpRequest.get("/test/logout").contextPath("/test")); + filter.filter(exchange, (e) -> Mono.empty()).block(); + } + } From 39c3a061e2fcc5cd7b2ff747512ad2c91d004633 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Fri, 9 Aug 2024 22:29:15 +0200 Subject: [PATCH 2/3] Render reactive default UIs using lightweight templates --- .../security/web/server/ui/HtmlTemplates.java | 108 +++++++++++ .../ui/LoginPageGeneratingWebFilter.java | 144 +++++++++------ .../ui/LogoutPageGeneratingWebFilter.java | 60 +++--- .../ui/LoginPageGeneratingWebFilterTests.java | 173 ++++++++++++++++++ .../LogoutPageGeneratingWebFilterTests.java | 145 +++++++++++++++ 5 files changed, 549 insertions(+), 81 deletions(-) create mode 100644 web/src/main/java/org/springframework/security/web/server/ui/HtmlTemplates.java diff --git a/web/src/main/java/org/springframework/security/web/server/ui/HtmlTemplates.java b/web/src/main/java/org/springframework/security/web/server/ui/HtmlTemplates.java new file mode 100644 index 00000000000..432b1b65a57 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/ui/HtmlTemplates.java @@ -0,0 +1,108 @@ +/* + * 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.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.springframework.util.StringUtils; +import org.springframework.web.util.HtmlUtils; + +/** + * Render HTML templates using string substitution. Intended for internal use. Variables + * can be templated using double curly-braces: {@code {{name}}}. + * + * @author Daniel Garnier-Moiroux + * @since 6.4 + * @see org.springframework.security.web.authentication.ui.HtmlTemplates + */ +final class HtmlTemplates { + + private HtmlTemplates() { + } + + static Builder fromTemplate(String template) { + return new Builder(template); + } + + static final class Builder { + + private final String template; + + private final Map values = new HashMap<>(); + + private Builder(String template) { + this.template = template; + } + + /** + * HTML-escape, and inject value {@code value} in every {@code {{key}}} + * placeholder. + * @param key the placeholder name + * @param value the value to inject + * @return this instance for further templating + */ + Builder withValue(String key, String value) { + this.values.put(key, HtmlUtils.htmlEscape(value)); + return this; + } + + /** + * Inject value {@code value} in every {@code {{key}}} placeholder without + * HTML-escaping. Useful for injecting "sub-templates". + * @param key the placeholder name + * @param value the value to inject + * @return this instance for further templating + */ + Builder withRawHtml(String key, String value) { + if (!value.isEmpty() && value.charAt(value.length() - 1) == '\n') { + value = value.substring(0, value.length() - 1); + } + this.values.put(key, value); + return this; + } + + /** + * Render the template. All placeholders MUST have a corresponding value. If a + * placeholder does not have a corresponding value, throws + * {@link IllegalStateException}. + * @return the rendered template + */ + String render() { + String template = this.template; + for (String key : this.values.keySet()) { + String pattern = Pattern.quote("{{" + key + "}}"); + template = template.replaceAll(pattern, this.values.get(key)); + } + + String unusedPlaceholders = Pattern.compile("\\{\\{([a-zA-Z0-9]+)}}") + .matcher(template) + .results() + .map((result) -> result.group(1)) + .collect(Collectors.joining(", ")); + if (StringUtils.hasLength(unusedPlaceholders)) { + throw new IllegalStateException("Unused placeholders in template: [%s]".formatted(unusedPlaceholders)); + } + + return template; + } + + } + +} diff --git a/web/src/main/java/org/springframework/security/web/server/ui/LoginPageGeneratingWebFilter.java b/web/src/main/java/org/springframework/security/web/server/ui/LoginPageGeneratingWebFilter.java index 3065796ea4e..3a18738448e 100644 --- a/web/src/main/java/org/springframework/security/web/server/ui/LoginPageGeneratingWebFilter.java +++ b/web/src/main/java/org/springframework/security/web/server/ui/LoginPageGeneratingWebFilter.java @@ -19,6 +19,7 @@ import java.nio.charset.Charset; import java.util.HashMap; import java.util.Map; +import java.util.stream.Collectors; import reactor.core.publisher.Mono; @@ -37,7 +38,6 @@ import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; -import org.springframework.web.util.HtmlUtils; /** * Generates a default log in page used for authenticating users. @@ -89,80 +89,61 @@ private Mono createBuffer(ServerWebExchange exchange) { private byte[] createPage(ServerWebExchange exchange, String csrfTokenHtmlInput) { MultiValueMap queryParams = exchange.getRequest().getQueryParams(); String contextPath = exchange.getRequest().getPath().contextPath().value(); - StringBuilder page = new StringBuilder(); - page.append("\n"); - page.append("\n"); - page.append(" \n"); - page.append(" \n"); - page.append(" \n"); - page.append(" \n"); - page.append(" \n"); - page.append(" Please sign in\n"); - page.append(CssUtils.getCssStyleBlock().indent(4)); - page.append(" \n"); - page.append(" \n"); - page.append("
    \n"); - page.append(formLogin(queryParams, contextPath, csrfTokenHtmlInput)); - page.append(oauth2LoginLinks(queryParams, contextPath, this.oauth2AuthenticationUrlToClientName)); - page.append("
    \n"); - page.append(" \n"); - page.append(""); - return page.toString().getBytes(Charset.defaultCharset()); + + return HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE) + .withRawHtml("cssStyle", CssUtils.getCssStyleBlock().indent(4)) + .withRawHtml("formLogin", formLogin(queryParams, contextPath, csrfTokenHtmlInput)) + .withRawHtml("oauth2Login", oauth2Login(queryParams, contextPath, this.oauth2AuthenticationUrlToClientName)) + .render() + .getBytes(Charset.defaultCharset()); } private String formLogin(MultiValueMap queryParams, String contextPath, String csrfTokenHtmlInput) { if (!this.formLoginEnabled) { return ""; } + boolean isError = queryParams.containsKey("error"); boolean isLogoutSuccess = queryParams.containsKey("logout"); - StringBuilder page = new StringBuilder(); - page.append("
    \n"); - page.append("

    Please sign in

    \n"); - page.append(createError(isError)); - page.append(createLogoutSuccess(isLogoutSuccess)); - page.append("

    \n"); - page.append(" \n"); - page.append(" \n"); - page.append("

    \n" + "

    \n"); - page.append(" \n"); - page.append(" \n"); - page.append("

    \n"); - page.append(csrfTokenHtmlInput); - page.append(" \n"); - page.append("
    \n"); - return page.toString(); + + return HtmlTemplates.fromTemplate(LOGIN_FORM_TEMPLATE) + .withValue("loginUrl", contextPath + "/login") + .withRawHtml("errorMessage", createError(isError)) + .withRawHtml("logoutMessage", createLogoutSuccess(isLogoutSuccess)) + .withRawHtml("csrf", csrfTokenHtmlInput) + .render(); } - private static String oauth2LoginLinks(MultiValueMap queryParams, String contextPath, + private static String oauth2Login(MultiValueMap queryParams, String contextPath, Map oauth2AuthenticationUrlToClientName) { if (oauth2AuthenticationUrlToClientName.isEmpty()) { return ""; } boolean isError = queryParams.containsKey("error"); - StringBuilder sb = new StringBuilder(); - sb.append("

    Login with OAuth 2.0

    "); - sb.append(createError(isError)); - sb.append("\n"); - for (Map.Entry clientAuthenticationUrlToClientName : oauth2AuthenticationUrlToClientName - .entrySet()) { - sb.append(" \n"); - } - sb.append("
    "); - String url = clientAuthenticationUrlToClientName.getKey(); - sb.append(""); - String clientName = HtmlUtils.htmlEscape(clientAuthenticationUrlToClientName.getValue()); - sb.append(clientName); - sb.append(""); - sb.append("
    \n"); - return sb.toString(); + + String oauth2Rows = oauth2AuthenticationUrlToClientName.entrySet() + .stream() + .map((urlToName) -> oauth2LoginLink(contextPath, urlToName.getKey(), urlToName.getValue())) + .collect(Collectors.joining("\n")) + .indent(2); + return HtmlTemplates.fromTemplate(OAUTH2_LOGIN_TEMPLATE) + .withRawHtml("errorMessage", createError(isError)) + .withRawHtml("oauth2Rows", oauth2Rows) + .render(); + } + + private static String oauth2LoginLink(String contextPath, String url, String clientName) { + return HtmlTemplates.fromTemplate(OAUTH2_ROW_TEMPLATE) + .withValue("url", contextPath + url) + .withValue("clientName", clientName) + .render(); } private static String csrfToken(CsrfToken token) { - return " \n"; + return HtmlTemplates.fromTemplate(CSRF_INPUT_TEMPLATE) + .withValue("name", token.getParameterName()) + .withValue("value", token.getToken()) + .render(); } private static String createError(boolean isError) { @@ -174,4 +155,53 @@ private static String createLogoutSuccess(boolean isLogoutSuccess) { : ""; } + private static final String LOGIN_PAGE_TEMPLATE = """ + + + + + + + + Please sign in + {{cssStyle}} + + +
    + {{formLogin}} + {{oauth2Login}} +
    + + """; + + private static final String LOGIN_FORM_TEMPLATE = """ + """; + + private static final String CSRF_INPUT_TEMPLATE = """ + + """; + + private static final String OAUTH2_LOGIN_TEMPLATE = """ +

    Login with OAuth 2.0

    + {{errorMessage}} + + {{oauth2Rows}} +
    """; + + private static final String OAUTH2_ROW_TEMPLATE = """ +
    {{clientName}}"""; + } diff --git a/web/src/main/java/org/springframework/security/web/server/ui/LogoutPageGeneratingWebFilter.java b/web/src/main/java/org/springframework/security/web/server/ui/LogoutPageGeneratingWebFilter.java index a691e2fdcbb..34e850f80b3 100644 --- a/web/src/main/java/org/springframework/security/web/server/ui/LogoutPageGeneratingWebFilter.java +++ b/web/src/main/java/org/springframework/security/web/server/ui/LogoutPageGeneratingWebFilter.java @@ -70,33 +70,45 @@ private Mono createBuffer(ServerWebExchange exchange) { } private static byte[] createPage(String csrfTokenHtmlInput, String contextPath) { - StringBuilder page = new StringBuilder(); - page.append("\n"); - page.append("\n"); - page.append(" \n"); - page.append(" \n"); - page.append(" \n"); - page.append(" \n"); - page.append(" \n"); - page.append(" Confirm Log Out?\n"); - page.append(CssUtils.getCssStyleBlock().indent(4)); - page.append(" \n"); - page.append(" \n"); - page.append("
    \n"); - page.append("
    \n"); - page.append("

    Are you sure you want to log out?

    \n"); - page.append(csrfTokenHtmlInput); - page.append(" \n"); - page.append("
    \n"); - page.append("
    \n"); - page.append(" \n"); - page.append(""); - return page.toString().getBytes(Charset.defaultCharset()); + return HtmlTemplates.fromTemplate(LOGOUT_PAGE_TEMPLATE) + .withRawHtml("cssStyle", CssUtils.getCssStyleBlock().indent(4)) + .withValue("contextPath", contextPath) + .withRawHtml("csrf", csrfTokenHtmlInput.indent(8)) + .render() + .getBytes(Charset.defaultCharset()); } private static String csrfToken(CsrfToken token) { - return " \n"; + return HtmlTemplates.fromTemplate(CSRF_INPUT_TEMPLATE) + .withValue("name", token.getParameterName()) + .withValue("value", token.getToken()) + .render(); } + private static final String LOGOUT_PAGE_TEMPLATE = """ + + + + + + + + Confirm Log Out? + {{cssStyle}} + + +
    +
    +

    Are you sure you want to log out?

    + {{csrf}} + +
    +
    + + """; + + private static final String CSRF_INPUT_TEMPLATE = """ + + """; + } diff --git a/web/src/test/java/org/springframework/security/web/server/ui/LoginPageGeneratingWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/ui/LoginPageGeneratingWebFilterTests.java index 731097013b3..6bbbc307e09 100644 --- a/web/src/test/java/org/springframework/security/web/server/ui/LoginPageGeneratingWebFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/ui/LoginPageGeneratingWebFilterTests.java @@ -16,6 +16,8 @@ package org.springframework.security.web.server.ui; +import java.util.Collections; + import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; @@ -45,4 +47,175 @@ public void filterWhenLoginWithNoContextPathThenActionDoesNotContainsContextPath assertThat(exchange.getResponse().getBodyAsString().block()).contains("action=\"/login\""); } + @Test + void filtersThenRendersPage() { + String clientName = "Google < > \" \' &"; + LoginPageGeneratingWebFilter filter = new LoginPageGeneratingWebFilter(); + filter.setOauth2AuthenticationUrlToClientName( + Collections.singletonMap("/oauth2/authorization/google", clientName)); + filter.setFormLoginEnabled(true); + MockServerWebExchange exchange = MockServerWebExchange + .from(MockServerHttpRequest.get("/test/login").contextPath("/test")); + filter.filter(exchange, (e) -> Mono.empty()).block(); + assertThat(exchange.getResponse().getBodyAsString().block()).isEqualTo(""" + + + + + + + + Please sign in + + + +
    + +

    Login with OAuth 2.0

    + + + +
    Google < > " ' &
    +
    + + """); + } + } diff --git a/web/src/test/java/org/springframework/security/web/server/ui/LogoutPageGeneratingWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/ui/LogoutPageGeneratingWebFilterTests.java index a3b5b29ab2a..fc5547767e3 100644 --- a/web/src/test/java/org/springframework/security/web/server/ui/LogoutPageGeneratingWebFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/ui/LogoutPageGeneratingWebFilterTests.java @@ -49,6 +49,151 @@ void filterThenRendersPage() { MockServerWebExchange exchange = MockServerWebExchange .from(MockServerHttpRequest.get("/test/logout").contextPath("/test")); filter.filter(exchange, (e) -> Mono.empty()).block(); + assertThat(exchange.getResponse().getBodyAsString().block()).isEqualTo(""" + + + + + + + + Confirm Log Out? + + + +
    +
    +

    Are you sure you want to log out?

    + + +
    +
    + + """); } } From 194ccbcb9ac5837977a6a462fc1d006a78582727 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 5 Sep 2024 15:12:52 +0200 Subject: [PATCH 3/3] Render One Time Token UIs using lightweight templates --- ...neTimeTokenSubmitPageGeneratingFilter.java | 102 ++++++----- ...eTokenSubmitPageGeneratingFilterTests.java | 169 +++++++++++++++++- 2 files changed, 222 insertions(+), 49 deletions(-) diff --git a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilter.java index 8d47ebc7bd4..86681958ae0 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilter.java +++ b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilter.java @@ -21,6 +21,7 @@ import java.util.Collections; import java.util.Map; import java.util.function.Function; +import java.util.stream.Collectors; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -33,7 +34,6 @@ import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; -import org.springframework.web.util.HtmlUtils; /** * Creates a default one-time token submit page. If the request contains a {@code token} @@ -65,54 +65,27 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse private String generateHtml(HttpServletRequest request) { String token = request.getParameter("token"); - String inputValue = StringUtils.hasText(token) ? HtmlUtils.htmlEscape(token) : ""; - String input = ""; - return """ - - - - One-Time Token Login - - - - """ - + CssUtils.getCssStyleBlock().indent(4) - + """ - - - -
    - """ - + "
    " + """ -

    Please input the token

    -

    - - """ + input + """ -

    - - """ + renderHiddenInputs(request) + """ -
    -
    - - - """; + String tokenValue = StringUtils.hasText(token) ? token : ""; + + String hiddenInputs = this.resolveHiddenInputs.apply(request) + .entrySet() + .stream() + .map((inputKeyValue) -> renderHiddenInput(inputKeyValue.getKey(), inputKeyValue.getValue())) + .collect(Collectors.joining("\n")); + + return HtmlTemplates.fromTemplate(ONE_TIME_TOKEN_SUBMIT_PAGE_TEMPLATE) + .withRawHtml("cssStyle", CssUtils.getCssStyleBlock().indent(4)) + .withValue("tokenValue", tokenValue) + .withValue("loginProcessingUrl", this.loginProcessingUrl) + .withRawHtml("hiddenInputs", hiddenInputs) + .render(); } - private String renderHiddenInputs(HttpServletRequest request) { - StringBuilder sb = new StringBuilder(); - for (Map.Entry input : this.resolveHiddenInputs.apply(request).entrySet()) { - sb.append("\n"); - } - return sb.toString(); + private String renderHiddenInput(String name, String value) { + return HtmlTemplates.fromTemplate(HIDDEN_HTML_INPUT_TEMPLATE) + .withValue("name", name) + .withValue("value", value) + .render(); } public void setResolveHiddenInputs(Function> resolveHiddenInputs) { @@ -135,4 +108,39 @@ public void setLoginProcessingUrl(String loginProcessingUrl) { this.loginProcessingUrl = loginProcessingUrl; } + private static final String ONE_TIME_TOKEN_SUBMIT_PAGE_TEMPLATE = """ + + + + One-Time Token Login + + + + {{cssStyle}} + + + +
    + +
    + + + """; + + private static final String HIDDEN_HTML_INPUT_TEMPLATE = """ + + """; + } diff --git a/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilterTests.java b/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilterTests.java index df4eab303d9..f92bfbedb44 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilterTests.java @@ -16,6 +16,8 @@ package org.springframework.security.web.authentication.ui; +import java.util.Map; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -72,8 +74,7 @@ void setLoginProcessingUrlThenUseItForFormAction() throws Exception { this.filter.setLoginProcessingUrl("/login/another"); this.filter.doFilterInternal(this.request, this.response, this.filterChain); String response = this.response.getContentAsString(); - assertThat(response).contains( - "
    \t

    Please input the token

    "); + assertThat(response).contains(""); } @Test @@ -85,4 +86,168 @@ void filterWhenTokenQueryParamUsesSpecialCharactersThenValueIsEscaped() throws E ""); } + @Test + void filterThenRenders() throws Exception { + this.request.setParameter("token", "this<>!@#\""); + this.filter.setLoginProcessingUrl("/login/another"); + this.filter.setResolveHiddenInputs((request) -> Map.of("_csrf", "csrf-token-value")); + this.filter.doFilterInternal(this.request, this.response, this.filterChain); + String response = this.response.getContentAsString(); + assertThat(response).isEqualTo( + """ + + + + One-Time Token Login + + + + + + + +
    + +

    Please input the token

    +

    + + +

    + + + +
    + + + """); + } + }