Render reactive default UIs using lightweight templates
This commit is contained in:
		
							parent
							
								
									8d47906191
								
							
						
					
					
						commit
						a642a1bb66
					
				| 
						 | 
				
			
			@ -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<String, String> 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;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -19,6 +19,7 @@ package org.springframework.security.web.server.ui;
 | 
			
		|||
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.util.MultiValueMap;
 | 
			
		|||
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 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
 | 
			
		|||
	private byte[] createPage(ServerWebExchange exchange, String csrfTokenHtmlInput) {
 | 
			
		||||
		MultiValueMap<String, String> queryParams = exchange.getRequest().getQueryParams();
 | 
			
		||||
		String contextPath = exchange.getRequest().getPath().contextPath().value();
 | 
			
		||||
		StringBuilder page = new StringBuilder();
 | 
			
		||||
		page.append("<!DOCTYPE html>\n");
 | 
			
		||||
		page.append("<html lang=\"en\">\n");
 | 
			
		||||
		page.append("  <head>\n");
 | 
			
		||||
		page.append("    <meta charset=\"utf-8\">\n");
 | 
			
		||||
		page.append("    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n");
 | 
			
		||||
		page.append("    <meta name=\"description\" content=\"\">\n");
 | 
			
		||||
		page.append("    <meta name=\"author\" content=\"\">\n");
 | 
			
		||||
		page.append("    <title>Please sign in</title>\n");
 | 
			
		||||
		page.append(CssUtils.getCssStyleBlock().indent(4));
 | 
			
		||||
		page.append("  </head>\n");
 | 
			
		||||
		page.append("  <body>\n");
 | 
			
		||||
		page.append("     <div class=\"content\">\n");
 | 
			
		||||
		page.append(formLogin(queryParams, contextPath, csrfTokenHtmlInput));
 | 
			
		||||
		page.append(oauth2LoginLinks(queryParams, contextPath, this.oauth2AuthenticationUrlToClientName));
 | 
			
		||||
		page.append("    </div>\n");
 | 
			
		||||
		page.append("  </body>\n");
 | 
			
		||||
		page.append("</html>");
 | 
			
		||||
		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<String, String> 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("      <form class=\"login-form\" method=\"post\" action=\"" + contextPath + "/login\">\n");
 | 
			
		||||
		page.append("        <h2>Please sign in</h2>\n");
 | 
			
		||||
		page.append(createError(isError));
 | 
			
		||||
		page.append(createLogoutSuccess(isLogoutSuccess));
 | 
			
		||||
		page.append("        <p>\n");
 | 
			
		||||
		page.append("          <label for=\"username\" class=\"screenreader\">Username</label>\n");
 | 
			
		||||
		page.append("          <input type=\"text\" id=\"username\" name=\"username\" "
 | 
			
		||||
				+ "placeholder=\"Username\" required autofocus>\n");
 | 
			
		||||
		page.append("        </p>\n" + "        <p>\n");
 | 
			
		||||
		page.append("          <label for=\"password\" class=\"screenreader\">Password</label>\n");
 | 
			
		||||
		page.append("          <input type=\"password\" id=\"password\" name=\"password\" "
 | 
			
		||||
				+ "placeholder=\"Password\" required>\n");
 | 
			
		||||
		page.append("        </p>\n");
 | 
			
		||||
		page.append(csrfTokenHtmlInput);
 | 
			
		||||
		page.append("        <button class=\"primary\" type=\"submit\">Sign in</button>\n");
 | 
			
		||||
		page.append("      </form>\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<String, String> queryParams, String contextPath,
 | 
			
		||||
	private static String oauth2Login(MultiValueMap<String, String> queryParams, String contextPath,
 | 
			
		||||
			Map<String, String> oauth2AuthenticationUrlToClientName) {
 | 
			
		||||
		if (oauth2AuthenticationUrlToClientName.isEmpty()) {
 | 
			
		||||
			return "";
 | 
			
		||||
		}
 | 
			
		||||
		boolean isError = queryParams.containsKey("error");
 | 
			
		||||
		StringBuilder sb = new StringBuilder();
 | 
			
		||||
		sb.append("<div class=\"content\"><h2>Login with OAuth 2.0</h2>");
 | 
			
		||||
		sb.append(createError(isError));
 | 
			
		||||
		sb.append("<table class=\"table table-striped\">\n");
 | 
			
		||||
		for (Map.Entry<String, String> clientAuthenticationUrlToClientName : oauth2AuthenticationUrlToClientName
 | 
			
		||||
			.entrySet()) {
 | 
			
		||||
			sb.append(" <tr><td>");
 | 
			
		||||
			String url = clientAuthenticationUrlToClientName.getKey();
 | 
			
		||||
			sb.append("<a href=\"").append(contextPath).append(url).append("\">");
 | 
			
		||||
			String clientName = HtmlUtils.htmlEscape(clientAuthenticationUrlToClientName.getValue());
 | 
			
		||||
			sb.append(clientName);
 | 
			
		||||
			sb.append("</a>");
 | 
			
		||||
			sb.append("</td></tr>\n");
 | 
			
		||||
		}
 | 
			
		||||
		sb.append("</table></div>\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 "          <input type=\"hidden\" name=\"" + token.getParameterName() + "\" value=\"" + token.getToken()
 | 
			
		||||
				+ "\">\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 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
 | 
			
		|||
				: "";
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private static final String LOGIN_PAGE_TEMPLATE = """
 | 
			
		||||
			<!DOCTYPE html>
 | 
			
		||||
			<html lang="en">
 | 
			
		||||
			  <head>
 | 
			
		||||
			    <meta charset="utf-8">
 | 
			
		||||
			    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
 | 
			
		||||
			    <meta name="description" content="">
 | 
			
		||||
			    <meta name="author" content="">
 | 
			
		||||
			    <title>Please sign in</title>
 | 
			
		||||
			{{cssStyle}}
 | 
			
		||||
			  </head>
 | 
			
		||||
			  <body>
 | 
			
		||||
			    <div class="content">
 | 
			
		||||
			{{formLogin}}
 | 
			
		||||
			{{oauth2Login}}
 | 
			
		||||
			    </div>
 | 
			
		||||
			  </body>
 | 
			
		||||
			</html>""";
 | 
			
		||||
 | 
			
		||||
	private static final String LOGIN_FORM_TEMPLATE = """
 | 
			
		||||
			      <form class="login-form" method="post" action="{{loginUrl}}">
 | 
			
		||||
			        <h2>Please sign in</h2>
 | 
			
		||||
			{{errorMessage}}{{logoutMessage}}
 | 
			
		||||
			        <p>
 | 
			
		||||
			          <label for="username" class="screenreader">Username</label>
 | 
			
		||||
			          <input type="text" id="username" name="username" placeholder="Username" required autofocus>
 | 
			
		||||
			        </p>
 | 
			
		||||
			        <p>
 | 
			
		||||
			          <label for="password" class="screenreader">Password</label>
 | 
			
		||||
			          <input type="password" id="password" name="password" placeholder="Password" required>
 | 
			
		||||
			        </p>
 | 
			
		||||
			{{csrf}}
 | 
			
		||||
			        <button type="submit" class="primary">Sign in</button>
 | 
			
		||||
			      </form>""";
 | 
			
		||||
 | 
			
		||||
	private static final String CSRF_INPUT_TEMPLATE = """
 | 
			
		||||
			<input name="{{name}}" type="hidden" value="{{value}}" />
 | 
			
		||||
			""";
 | 
			
		||||
 | 
			
		||||
	private static final String OAUTH2_LOGIN_TEMPLATE = """
 | 
			
		||||
			<h2>Login with OAuth 2.0</h2>
 | 
			
		||||
			{{errorMessage}}
 | 
			
		||||
			<table class="table table-striped">
 | 
			
		||||
			{{oauth2Rows}}
 | 
			
		||||
			</table>""";
 | 
			
		||||
 | 
			
		||||
	private static final String OAUTH2_ROW_TEMPLATE = """
 | 
			
		||||
			<tr><td><a href="{{url}}">{{clientName}}</a></td></tr>""";
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -70,33 +70,45 @@ public class LogoutPageGeneratingWebFilter implements WebFilter {
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	private static byte[] createPage(String csrfTokenHtmlInput, String contextPath) {
 | 
			
		||||
		StringBuilder page = new StringBuilder();
 | 
			
		||||
		page.append("<!DOCTYPE html>\n");
 | 
			
		||||
		page.append("<html lang=\"en\">\n");
 | 
			
		||||
		page.append("  <head>\n");
 | 
			
		||||
		page.append("    <meta charset=\"utf-8\">\n");
 | 
			
		||||
		page.append("    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n");
 | 
			
		||||
		page.append("    <meta name=\"description\" content=\"\">\n");
 | 
			
		||||
		page.append("    <meta name=\"author\" content=\"\">\n");
 | 
			
		||||
		page.append("    <title>Confirm Log Out?</title>\n");
 | 
			
		||||
		page.append(CssUtils.getCssStyleBlock().indent(4));
 | 
			
		||||
		page.append("  </head>\n");
 | 
			
		||||
		page.append("  <body>\n");
 | 
			
		||||
		page.append("     <div class=\"content\">\n");
 | 
			
		||||
		page.append("      <form class=\"logout-form\" method=\"post\" action=\"" + contextPath + "/logout\">\n");
 | 
			
		||||
		page.append("        <h2>Are you sure you want to log out?</h2>\n");
 | 
			
		||||
		page.append(csrfTokenHtmlInput);
 | 
			
		||||
		page.append("        <button class=\"primary\" type=\"submit\">Log Out</button>\n");
 | 
			
		||||
		page.append("      </form>\n");
 | 
			
		||||
		page.append("    </div>\n");
 | 
			
		||||
		page.append("  </body>\n");
 | 
			
		||||
		page.append("</html>");
 | 
			
		||||
		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 "          <input type=\"hidden\" name=\"" + token.getParameterName() + "\" value=\"" + token.getToken()
 | 
			
		||||
				+ "\">\n";
 | 
			
		||||
		return HtmlTemplates.fromTemplate(CSRF_INPUT_TEMPLATE)
 | 
			
		||||
			.withValue("name", token.getParameterName())
 | 
			
		||||
			.withValue("value", token.getToken())
 | 
			
		||||
			.render();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private static final String LOGOUT_PAGE_TEMPLATE = """
 | 
			
		||||
			<!DOCTYPE html>
 | 
			
		||||
			<html lang="en">
 | 
			
		||||
			  <head>
 | 
			
		||||
			    <meta charset="utf-8">
 | 
			
		||||
			    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
 | 
			
		||||
			    <meta name="description" content="">
 | 
			
		||||
			    <meta name="author" content="">
 | 
			
		||||
			    <title>Confirm Log Out?</title>
 | 
			
		||||
			{{cssStyle}}
 | 
			
		||||
			  </head>
 | 
			
		||||
			  <body>
 | 
			
		||||
			    <div class="content">
 | 
			
		||||
			      <form class="logout-form" method="post" action="{{contextPath}}/logout">
 | 
			
		||||
			        <h2>Are you sure you want to log out?</h2>
 | 
			
		||||
			{{csrf}}
 | 
			
		||||
			        <button class="primary" type="submit">Log Out</button>
 | 
			
		||||
			      </form>
 | 
			
		||||
			    </div>
 | 
			
		||||
			  </body>
 | 
			
		||||
			</html>""";
 | 
			
		||||
 | 
			
		||||
	private static final String CSRF_INPUT_TEMPLATE = """
 | 
			
		||||
			<input name="{{name}}" type="hidden" value="{{value}}" />
 | 
			
		||||
			""";
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 class LoginPageGeneratingWebFilterTests {
 | 
			
		|||
		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("""
 | 
			
		||||
				<!DOCTYPE html>
 | 
			
		||||
				<html lang="en">
 | 
			
		||||
				  <head>
 | 
			
		||||
				    <meta charset="utf-8">
 | 
			
		||||
				    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
 | 
			
		||||
				    <meta name="description" content="">
 | 
			
		||||
				    <meta name="author" content="">
 | 
			
		||||
				    <title>Please sign in</title>
 | 
			
		||||
				    <style>
 | 
			
		||||
				    /* General layout */
 | 
			
		||||
				    body {
 | 
			
		||||
				      font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
 | 
			
		||||
				      background-color: #eee;
 | 
			
		||||
				      padding: 40px 0;
 | 
			
		||||
				      margin: 0;
 | 
			
		||||
				      line-height: 1.5;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    h2 {
 | 
			
		||||
				      margin-top: 0;
 | 
			
		||||
				      margin-bottom: 0.5rem;
 | 
			
		||||
				      font-size: 2rem;
 | 
			
		||||
				      font-weight: 500;
 | 
			
		||||
				      line-height: 2rem;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    .content {
 | 
			
		||||
				      margin-right: auto;
 | 
			
		||||
				      margin-left: auto;
 | 
			
		||||
				      padding-right: 15px;
 | 
			
		||||
				      padding-left: 15px;
 | 
			
		||||
				      width: 100%;
 | 
			
		||||
				      box-sizing: border-box;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    @media (min-width: 800px) {
 | 
			
		||||
				      .content {
 | 
			
		||||
				        max-width: 760px;
 | 
			
		||||
				      }
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    /* Components */
 | 
			
		||||
				    a,
 | 
			
		||||
				    a:visited {
 | 
			
		||||
				      text-decoration: none;
 | 
			
		||||
				      color: #06f;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    a:hover {
 | 
			
		||||
				      text-decoration: underline;
 | 
			
		||||
				      color: #003c97;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    input[type="text"],
 | 
			
		||||
				    input[type="password"] {
 | 
			
		||||
				      height: auto;
 | 
			
		||||
				      width: 100%;
 | 
			
		||||
				      font-size: 1rem;
 | 
			
		||||
				      padding: 0.5rem;
 | 
			
		||||
				      box-sizing: border-box;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    button {
 | 
			
		||||
				      padding: 0.5rem 1rem;
 | 
			
		||||
				      font-size: 1.25rem;
 | 
			
		||||
				      line-height: 1.5;
 | 
			
		||||
				      border: none;
 | 
			
		||||
				      border-radius: 0.1rem;
 | 
			
		||||
				      width: 100%;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    button.primary {
 | 
			
		||||
				      color: #fff;
 | 
			
		||||
				      background-color: #06f;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    .alert {
 | 
			
		||||
				      padding: 0.75rem 1rem;
 | 
			
		||||
				      margin-bottom: 1rem;
 | 
			
		||||
				      line-height: 1.5;
 | 
			
		||||
				      border-radius: 0.1rem;
 | 
			
		||||
				      width: 100%;
 | 
			
		||||
				      box-sizing: border-box;
 | 
			
		||||
				      border-width: 1px;
 | 
			
		||||
				      border-style: solid;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    .alert.alert-danger {
 | 
			
		||||
				      color: #6b1922;
 | 
			
		||||
				      background-color: #f7d5d7;
 | 
			
		||||
				      border-color: #eab6bb;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    .alert.alert-success {
 | 
			
		||||
				      color: #145222;
 | 
			
		||||
				      background-color: #d1f0d9;
 | 
			
		||||
				      border-color: #c2ebcb;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    .screenreader {
 | 
			
		||||
				      position: absolute;
 | 
			
		||||
				      clip: rect(0 0 0 0);
 | 
			
		||||
				      height: 1px;
 | 
			
		||||
				      width: 1px;
 | 
			
		||||
				      padding: 0;
 | 
			
		||||
				      border: 0;
 | 
			
		||||
				      overflow: hidden;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    table {
 | 
			
		||||
				      width: 100%;
 | 
			
		||||
				      max-width: 100%;
 | 
			
		||||
				      margin-bottom: 2rem;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    .table-striped tr:nth-of-type(2n + 1) {
 | 
			
		||||
				      background-color: #e1e1e1;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    td {
 | 
			
		||||
				      padding: 0.75rem;
 | 
			
		||||
				      vertical-align: top;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    /* Login / logout layouts */
 | 
			
		||||
				    .login-form,
 | 
			
		||||
				    .logout-form {
 | 
			
		||||
				      max-width: 340px;
 | 
			
		||||
				      padding: 0 15px 15px 15px;
 | 
			
		||||
				      margin: 0 auto 2rem auto;
 | 
			
		||||
				      box-sizing: border-box;
 | 
			
		||||
				    }
 | 
			
		||||
				    </style>
 | 
			
		||||
				  </head>
 | 
			
		||||
				  <body>
 | 
			
		||||
				    <div class="content">
 | 
			
		||||
				      <form class="login-form" method="post" action="/test/login">
 | 
			
		||||
				        <h2>Please sign in</h2>
 | 
			
		||||
 | 
			
		||||
				        <p>
 | 
			
		||||
				          <label for="username" class="screenreader">Username</label>
 | 
			
		||||
				          <input type="text" id="username" name="username" placeholder="Username" required autofocus>
 | 
			
		||||
				        </p>
 | 
			
		||||
				        <p>
 | 
			
		||||
				          <label for="password" class="screenreader">Password</label>
 | 
			
		||||
				          <input type="password" id="password" name="password" placeholder="Password" required>
 | 
			
		||||
				        </p>
 | 
			
		||||
 | 
			
		||||
				        <button type="submit" class="primary">Sign in</button>
 | 
			
		||||
				      </form>
 | 
			
		||||
				<h2>Login with OAuth 2.0</h2>
 | 
			
		||||
 | 
			
		||||
				<table class="table table-striped">
 | 
			
		||||
				  <tr><td><a href="/test/oauth2/authorization/google">Google < > " ' &</a></td></tr>
 | 
			
		||||
				</table>
 | 
			
		||||
				    </div>
 | 
			
		||||
				  </body>
 | 
			
		||||
				</html>""");
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -49,6 +49,151 @@ public class LogoutPageGeneratingWebFilterTests {
 | 
			
		|||
		MockServerWebExchange exchange = MockServerWebExchange
 | 
			
		||||
			.from(MockServerHttpRequest.get("/test/logout").contextPath("/test"));
 | 
			
		||||
		filter.filter(exchange, (e) -> Mono.empty()).block();
 | 
			
		||||
		assertThat(exchange.getResponse().getBodyAsString().block()).isEqualTo("""
 | 
			
		||||
				<!DOCTYPE html>
 | 
			
		||||
				<html lang="en">
 | 
			
		||||
				  <head>
 | 
			
		||||
				    <meta charset="utf-8">
 | 
			
		||||
				    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
 | 
			
		||||
				    <meta name="description" content="">
 | 
			
		||||
				    <meta name="author" content="">
 | 
			
		||||
				    <title>Confirm Log Out?</title>
 | 
			
		||||
				    <style>
 | 
			
		||||
				    /* General layout */
 | 
			
		||||
				    body {
 | 
			
		||||
				      font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
 | 
			
		||||
				      background-color: #eee;
 | 
			
		||||
				      padding: 40px 0;
 | 
			
		||||
				      margin: 0;
 | 
			
		||||
				      line-height: 1.5;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    h2 {
 | 
			
		||||
				      margin-top: 0;
 | 
			
		||||
				      margin-bottom: 0.5rem;
 | 
			
		||||
				      font-size: 2rem;
 | 
			
		||||
				      font-weight: 500;
 | 
			
		||||
				      line-height: 2rem;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    .content {
 | 
			
		||||
				      margin-right: auto;
 | 
			
		||||
				      margin-left: auto;
 | 
			
		||||
				      padding-right: 15px;
 | 
			
		||||
				      padding-left: 15px;
 | 
			
		||||
				      width: 100%;
 | 
			
		||||
				      box-sizing: border-box;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    @media (min-width: 800px) {
 | 
			
		||||
				      .content {
 | 
			
		||||
				        max-width: 760px;
 | 
			
		||||
				      }
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    /* Components */
 | 
			
		||||
				    a,
 | 
			
		||||
				    a:visited {
 | 
			
		||||
				      text-decoration: none;
 | 
			
		||||
				      color: #06f;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    a:hover {
 | 
			
		||||
				      text-decoration: underline;
 | 
			
		||||
				      color: #003c97;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    input[type="text"],
 | 
			
		||||
				    input[type="password"] {
 | 
			
		||||
				      height: auto;
 | 
			
		||||
				      width: 100%;
 | 
			
		||||
				      font-size: 1rem;
 | 
			
		||||
				      padding: 0.5rem;
 | 
			
		||||
				      box-sizing: border-box;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    button {
 | 
			
		||||
				      padding: 0.5rem 1rem;
 | 
			
		||||
				      font-size: 1.25rem;
 | 
			
		||||
				      line-height: 1.5;
 | 
			
		||||
				      border: none;
 | 
			
		||||
				      border-radius: 0.1rem;
 | 
			
		||||
				      width: 100%;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    button.primary {
 | 
			
		||||
				      color: #fff;
 | 
			
		||||
				      background-color: #06f;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    .alert {
 | 
			
		||||
				      padding: 0.75rem 1rem;
 | 
			
		||||
				      margin-bottom: 1rem;
 | 
			
		||||
				      line-height: 1.5;
 | 
			
		||||
				      border-radius: 0.1rem;
 | 
			
		||||
				      width: 100%;
 | 
			
		||||
				      box-sizing: border-box;
 | 
			
		||||
				      border-width: 1px;
 | 
			
		||||
				      border-style: solid;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    .alert.alert-danger {
 | 
			
		||||
				      color: #6b1922;
 | 
			
		||||
				      background-color: #f7d5d7;
 | 
			
		||||
				      border-color: #eab6bb;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    .alert.alert-success {
 | 
			
		||||
				      color: #145222;
 | 
			
		||||
				      background-color: #d1f0d9;
 | 
			
		||||
				      border-color: #c2ebcb;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    .screenreader {
 | 
			
		||||
				      position: absolute;
 | 
			
		||||
				      clip: rect(0 0 0 0);
 | 
			
		||||
				      height: 1px;
 | 
			
		||||
				      width: 1px;
 | 
			
		||||
				      padding: 0;
 | 
			
		||||
				      border: 0;
 | 
			
		||||
				      overflow: hidden;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    table {
 | 
			
		||||
				      width: 100%;
 | 
			
		||||
				      max-width: 100%;
 | 
			
		||||
				      margin-bottom: 2rem;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    .table-striped tr:nth-of-type(2n + 1) {
 | 
			
		||||
				      background-color: #e1e1e1;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    td {
 | 
			
		||||
				      padding: 0.75rem;
 | 
			
		||||
				      vertical-align: top;
 | 
			
		||||
				    }
 | 
			
		||||
				\s\s\s\s
 | 
			
		||||
				    /* Login / logout layouts */
 | 
			
		||||
				    .login-form,
 | 
			
		||||
				    .logout-form {
 | 
			
		||||
				      max-width: 340px;
 | 
			
		||||
				      padding: 0 15px 15px 15px;
 | 
			
		||||
				      margin: 0 auto 2rem auto;
 | 
			
		||||
				      box-sizing: border-box;
 | 
			
		||||
				    }
 | 
			
		||||
				    </style>
 | 
			
		||||
				  </head>
 | 
			
		||||
				  <body>
 | 
			
		||||
				    <div class="content">
 | 
			
		||||
				      <form class="logout-form" method="post" action="/test/logout">
 | 
			
		||||
				        <h2>Are you sure you want to log out?</h2>
 | 
			
		||||
 | 
			
		||||
				        <button class="primary" type="submit">Log Out</button>
 | 
			
		||||
				      </form>
 | 
			
		||||
				    </div>
 | 
			
		||||
				  </body>
 | 
			
		||||
				</html>""");
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue