From b2f7ba7f731a2886364dc868336d7ed1d87b1648 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Tue, 13 Aug 2024 15:22:36 +0200 Subject: [PATCH 1/3] Bump spring-javaformat to 0.0.43 --- .../annotation/web/configurers/saml2/Saml2LoginConfigurer.java | 1 + .../security/access/method/MethodSecurityMetadataSource.java | 1 + .../access/prepost/PrePostInvocationAttributeFactory.java | 1 + gradle/libs.versions.toml | 2 +- .../main/java/org/springframework/security/ldap/LdapUtils.java | 1 + .../security/ldap/userdetails/LdapUserDetailsManager.java | 1 + .../JwtIssuerAuthenticationManagerResolverDeprecatedTests.java | 2 ++ ...uerReactiveAuthenticationManagerResolverDeprecatedTests.java | 2 ++ 8 files changed, 10 insertions(+), 1 deletion(-) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java index 56b6052756a..29bcc10e6b5 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java @@ -217,6 +217,7 @@ public Saml2LoginConfigurer authenticationRequestResolver( * @since 6.0 * @deprecated Use {@link #authenticationRequestUriQuery} instead */ + @Deprecated public Saml2LoginConfigurer authenticationRequestUri(String authenticationRequestUri) { return authenticationRequestUriQuery(authenticationRequestUri); } diff --git a/core/src/main/java/org/springframework/security/access/method/MethodSecurityMetadataSource.java b/core/src/main/java/org/springframework/security/access/method/MethodSecurityMetadataSource.java index 2243236fa9c..1512415f0e9 100644 --- a/core/src/main/java/org/springframework/security/access/method/MethodSecurityMetadataSource.java +++ b/core/src/main/java/org/springframework/security/access/method/MethodSecurityMetadataSource.java @@ -34,6 +34,7 @@ * {@code } and {@code } instead or use * annotation-based or {@link AuthorizationManager}-based authorization */ +@Deprecated public interface MethodSecurityMetadataSource extends SecurityMetadataSource { Collection getAttributes(Method method, Class targetClass); diff --git a/core/src/main/java/org/springframework/security/access/prepost/PrePostInvocationAttributeFactory.java b/core/src/main/java/org/springframework/security/access/prepost/PrePostInvocationAttributeFactory.java index 90ef0602078..6c428f10506 100644 --- a/core/src/main/java/org/springframework/security/access/prepost/PrePostInvocationAttributeFactory.java +++ b/core/src/main/java/org/springframework/security/access/prepost/PrePostInvocationAttributeFactory.java @@ -26,6 +26,7 @@ * @see org.springframework.security.authorization.method.AuthorizationManagerAfterMethodInterceptor * @deprecated Use delegation with {@link AuthorizationManager} */ +@Deprecated public interface PrePostInvocationAttributeFactory extends AopInfrastructureBean { PreInvocationAttribute createPreInvocationAttribute(String preFilterAttribute, String filterObject, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0440211aab0..22dd107b5f3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] com-squareup-okhttp3 = "3.14.9" io-rsocket = "1.1.4" -io-spring-javaformat = "0.0.42" +io-spring-javaformat = "0.0.43" io-spring-nohttp = "0.0.11" jakarta-websocket = "2.2.0" org-apache-directory-server = "1.5.5" diff --git a/ldap/src/main/java/org/springframework/security/ldap/LdapUtils.java b/ldap/src/main/java/org/springframework/security/ldap/LdapUtils.java index a37ccb3074f..66b0e89e86e 100644 --- a/ldap/src/main/java/org/springframework/security/ldap/LdapUtils.java +++ b/ldap/src/main/java/org/springframework/security/ldap/LdapUtils.java @@ -101,6 +101,7 @@ public static String getRelativeName(String fullDn, Context baseCtx) throws Nami /** * Gets the full dn of a name by prepending the name of the context it is relative to. * If the name already contains the base name, it is returned unaltered. + * @deprecated Use {@link #getFullDn(LdapName, Context)} */ @Deprecated public static DistinguishedName getFullDn(DistinguishedName dn, Context baseCtx) throws NamingException { diff --git a/ldap/src/main/java/org/springframework/security/ldap/userdetails/LdapUserDetailsManager.java b/ldap/src/main/java/org/springframework/security/ldap/userdetails/LdapUserDetailsManager.java index 8db6ec99da3..f8326b7969a 100644 --- a/ldap/src/main/java/org/springframework/security/ldap/userdetails/LdapUserDetailsManager.java +++ b/ldap/src/main/java/org/springframework/security/ldap/userdetails/LdapUserDetailsManager.java @@ -287,6 +287,7 @@ public boolean userExists(String username) { * Creates a DN from a group name. * @param group the name of the group * @return the DN of the corresponding group, including the groupSearchBase + * @deprecated */ @Deprecated protected DistinguishedName buildGroupDn(String group) { diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerAuthenticationManagerResolverDeprecatedTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerAuthenticationManagerResolverDeprecatedTests.java index 9301ed104b4..8b58d68dd67 100644 --- a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerAuthenticationManagerResolverDeprecatedTests.java +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerAuthenticationManagerResolverDeprecatedTests.java @@ -49,6 +49,8 @@ /** * Tests for {@link JwtIssuerAuthenticationManagerResolver} + * + * @deprecated Superseded by {@link JwtIssuerAuthenticationManagerResolverTests} */ @Deprecated public class JwtIssuerAuthenticationManagerResolverDeprecatedTests { diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerReactiveAuthenticationManagerResolverDeprecatedTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerReactiveAuthenticationManagerResolverDeprecatedTests.java index 9e0c08f1a68..ee818814201 100644 --- a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerReactiveAuthenticationManagerResolverDeprecatedTests.java +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerReactiveAuthenticationManagerResolverDeprecatedTests.java @@ -52,6 +52,8 @@ /** * Tests for {@link JwtIssuerReactiveAuthenticationManagerResolver} + * + * @deprecated Superseded by {@link JwtIssuerReactiveAuthenticationManagerResolverTests} */ @Deprecated public class JwtIssuerReactiveAuthenticationManagerResolverDeprecatedTests { From 928a666cb82277078507e4e23eaabfa86ce21483 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 8 Aug 2024 09:06:25 +0200 Subject: [PATCH 2/3] Render default UIs using lightweight templates --- .../DefaultLoginPageConfigurerTests.java | 315 ++++++++++-------- .../FormLoginBeanDefinitionParserTests.java | 298 +++++++++-------- .../ui/DefaultLoginPageGeneratingFilter.java | 233 ++++++++----- .../ui/DefaultLogoutPageGeneratingFilter.java | 65 ++-- .../web/authentication/ui/HtmlTemplates.java | 79 +++++ ...DefaultLoginPageGeneratingFilterTests.java | 190 ++++++++++- ...efaultLogoutPageGeneratingFilterTests.java | 152 +++++++++ .../authentication/ui/HtmlTemplatesTests.java | 151 +++++++++ .../LogoutPageGeneratingWebFilterTests.java | 8 + 9 files changed, 1089 insertions(+), 402 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..fdcd2b448c9 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,140 @@ @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); @@ -226,9 +226,10 @@ public void loginPageThenDefaultLoginPageIsRendered() throws Exception { + "\n" + EXPECTED_HTML_HEAD + " \n" - + "
\n" + + "
\n" + "
\n" + "

Please sign in

\n" + + " \n" + "

\n" + " \n" + " \n" @@ -237,11 +238,15 @@ public void loginPageThenDefaultLoginPageIsRendered() throws Exception { + " \n" + " \n" + "

\n" + + "\n" + "\n" + " \n" + "
\n" - + "
\n" - + ""); + + "\n" + + "\n" + + "
\n" + + " \n" + + ""); }); // @formatter:on } @@ -267,21 +272,26 @@ public void loginPageWhenErrorThenDefaultLoginPageWithError() throws Exception { + "\n" + EXPECTED_HTML_HEAD + " \n" - + "
\n" + + "
\n" + "
\n" + "

Please sign in

\n" - + "
Bad credentials

\n" + + "

Bad credentials
\n" + + "

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

\n" + "

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

\n" + + "\n" + "\n" + " \n" + "
\n" - + "
\n" - + ""); + + "\n" + + "\n" + + "
\n" + + " \n" + + ""); }); // @formatter:on } @@ -311,10 +321,11 @@ public void loginPageWhenLoggedOutThenDefaultLoginPageWithLogoutMessage() throws + "\n" + EXPECTED_HTML_HEAD + " \n" - + "
\n" + + "
\n" + "
\n" + "

Please sign in

\n" - + "
You have been signed out

\n" + + "

You have been signed out
\n" + + "

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

\n" @@ -322,11 +333,15 @@ public void loginPageWhenLoggedOutThenDefaultLoginPageWithLogoutMessage() throws + " \n" + " \n" + "

\n" + + "\n" + "\n" + " \n" + "
\n" - + "
\n" - + ""); + + "\n" + + "\n" + + "
\n" + + " \n" + + ""); }); // @formatter:on } @@ -356,9 +371,10 @@ public void loginPageWhenRememberConfigureThenDefaultLoginPageWithRememberMeChec + "\n" + EXPECTED_HTML_HEAD + " \n" - + "
\n" + + "
\n" + "
\n" + "

Please sign in

\n" + + " \n" + "

\n" + " \n" + " \n" @@ -371,8 +387,11 @@ public void loginPageWhenRememberConfigureThenDefaultLoginPageWithRememberMeChec + "\n" + " \n" + "

\n" - + "
\n" - + ""); + + "\n" + + "\n" + + "
\n" + + " \n" + + ""); }); // @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..e470c19d3e2 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,140 @@ 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); @@ -193,9 +193,10 @@ public void getLoginWhenAutoConfigThenShowsDefaultLoginPage() throws Exception { + "\n" + EXPECTED_HTML_HEAD + " \n" - + "
\n" + + "
\n" + "
\n" + "

Please sign in

\n" + + " \n" + "

\n" + " \n" + " \n" @@ -204,10 +205,15 @@ public void getLoginWhenAutoConfigThenShowsDefaultLoginPage() throws Exception { + " \n" + " \n" + "

\n" + + "\n" + + "\n" + " \n" + "
\n" - + "
\n" - + ""; + + "\n" + + "\n" + + "
\n" + + " \n" + + ""; // @formatter:on this.mvc.perform(get("/login")).andExpect(content().string(expectedContent)); } @@ -226,9 +232,10 @@ public void getLoginWhenConfiguredWithCustomAttributesThenLoginPageReflects() th + "\n" + EXPECTED_HTML_HEAD + " \n" - + "
\n" + + "
\n" + "
\n" + "

Please sign in

\n" + + " \n" + "

\n" + " \n" + " \n" @@ -237,15 +244,18 @@ public void getLoginWhenConfiguredWithCustomAttributesThenLoginPageReflects() th + " \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()); + + "\n" + + "\n" + + "
\n" + + " \n" + + ""; // @formatter:on + 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 89b44ecf62c..7401bcb3028 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 @@ -193,74 +193,84 @@ 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("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.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"); + + var 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 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) { @@ -278,23 +288,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) { @@ -309,14 +317,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 ""; } @@ -342,4 +350,67 @@ 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}} + {{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; + } 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..69980dda474 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ui/HtmlTemplates.java @@ -0,0 +1,79 @@ +/* + * 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 org.springframework.web.util.HtmlUtils; + +/** + * Render HTML templates using string substitution. Intended for internal use. + * + * @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; + } + + Builder withValue(String key, String value) { + this.values.put(key, HtmlUtils.htmlEscape(value)); + return this; + } + + 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; + } + + String render() { + String template = this.template; + for (String key : this.values.keySet()) { + String pattern = Pattern.quote("{{" + key + "}}"); + template = template.replaceFirst(pattern, this.values.get(key)); + } + + // Remove all placeholders left + String placeholderRegex = Pattern.compile("\\{\\{[a-zA-Z0-9]+}}").toString(); + template = template.replaceAll(placeholderRegex, ""); + + 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 12fdacdcf89..217c0dfaf5a 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * 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. @@ -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; @@ -185,4 +187,190 @@ public void generatesWhenExceptionWithEmptyMessageThenInvalidCredentials() throw assertThat(response.getContentAsString()).contains("Invalid credentials"); } + @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..faf7c2f628f --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/ui/HtmlTemplatesTests.java @@ -0,0 +1,151 @@ +/* + * 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; + +/** + * @author Daniel Garnier-Moiroux + * @since 6.4 + */ +class HtmlTemplatesTests { + + @Test + void processTemplateWhenNoVariablesThenRendersTemplate() { + var 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() { + var template = """ +
    +
  • {{one}}
  • +
  • {{two}}
  • +
+ """; + + var 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() { + var template = "

{{content}}

"; + + var 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() { + var template = """ +

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

+ """; + + var 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() { + var template = """ +
    + {{content}} +
+ """; + + var 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() { + var template = """ +
  • One: {{one}}
  • + {{two}} + """; + + var renderedTemplate = HtmlTemplates.fromTemplate(template) + .withValue("one", "") + .withRawHtml("two", "") + .render(); + + assertThat(renderedTemplate).isEqualTo(""" +
  • One:
  • + + """); + } + + @Test + void renderWhenMissingVariablesThenRemovesPlaceholders() { + var template = """ +
  • One: {{one}}
  • +
  • Two: {{two}}
  • + {{three}} + """; + + var renderedTemplate = HtmlTemplates.fromTemplate(template) + .withValue("one", "Lorem ipsum dolor sit amet") + .render(); + + assertThat(renderedTemplate).isEqualTo(""" +
  • One: Lorem ipsum dolor sit amet
  • +
  • Two:
  • + + """); + } + +} 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 87ba3d0d248dca36049c65ba22c08cb6b85662c3 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Fri, 9 Aug 2024 22:29:15 +0200 Subject: [PATCH 3/3] Render reactive default UIs using lightweight templates --- .../security/web/server/ui/HtmlTemplates.java | 80 ++++++++ .../ui/LoginPageGeneratingWebFilter.java | 144 +++++++++------ .../ui/LogoutPageGeneratingWebFilter.java | 60 +++--- .../ui/LoginPageGeneratingWebFilterTests.java | 173 ++++++++++++++++++ .../LogoutPageGeneratingWebFilterTests.java | 145 +++++++++++++++ 5 files changed, 521 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..03b1ec40f95 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/ui/HtmlTemplates.java @@ -0,0 +1,80 @@ +/* + * 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 org.springframework.web.util.HtmlUtils; + +/** + * Render HTML templates using string substitution. Intended for internal use. + * + * @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; + } + + Builder withValue(String key, String value) { + this.values.put(key, HtmlUtils.htmlEscape(value)); + return this; + } + + 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; + } + + String render() { + String template = this.template; + for (String key : this.values.keySet()) { + String pattern = Pattern.quote("{{" + key + "}}"); + template = template.replaceFirst(pattern, this.values.get(key)); + } + + // Remove all placeholders left + String placeholderRegex = Pattern.compile("\\{\\{[a-zA-Z0-9]+}}").toString(); + template = template.replaceAll(placeholderRegex, ""); + + 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..5592be3876c 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..f442be775e3 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?

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