瀏覽代碼

Render One Time Token UIs using lightweight templates

Daniel Garnier-Moiroux 11 月之前
父節點
當前提交
ef31ae1a98

+ 55 - 47
web/src/main/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilter.java

@@ -21,6 +21,7 @@ import java.nio.charset.StandardCharsets;
 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.security.web.util.matcher.RequestMatcher;
 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 @@ public final class DefaultOneTimeTokenSubmitPageGeneratingFilter extends OncePer
 
 	private String generateHtml(HttpServletRequest request) {
 		String token = request.getParameter("token");
-		String inputValue = StringUtils.hasText(token) ? HtmlUtils.htmlEscape(token) : "";
-		String input = "<input type=\"text\" id=\"token\" name=\"token\" value=\"" + inputValue + "\""
-				+ " placeholder=\"Token\" required=\"true\" autofocus=\"autofocus\"/>";
-		return """
-				<!DOCTYPE html>
-				<html lang="en">
-				<head>
-					<title>One-Time Token Login</title>
-					<meta charset="utf-8"/>
-					<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
-					<meta http-equiv="Content-Security-Policy" content="script-src 'sha256-oZhLbc2kO8b8oaYLrUc7uye1MgVKMyLtPqWR4WtKF+c='"/>
-				"""
-				+ CssUtils.getCssStyleBlock().indent(4)
-				+ """
-						</head>
-						<body>
-							<noscript>
-								<p>
-									<strong>Note:</strong> Since your browser does not support JavaScript, you must press the Sign In button once to proceed.
-								</p>
-							</noscript>
-							<div class="container">
-						"""
-				+ "<form class=\"login-form\" action=\"" + this.loginProcessingUrl + "\" method=\"post\">" + """
-							<h2>Please input the token</h2>
-							<p>
-								<label for="token" class="screenreader">Token</label>
-						""" + input + """
-								</p>
-								<button class="primary" type="submit">Sign in</button>
-						""" + renderHiddenInputs(request) + """
-							</form>
-						</div>
-						</body>
-						</html>
-						""";
+		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<String, String> input : this.resolveHiddenInputs.apply(request).entrySet()) {
-			sb.append("<input name=\"");
-			sb.append(input.getKey());
-			sb.append("\" type=\"hidden\" value=\"");
-			sb.append(input.getValue());
-			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<HttpServletRequest, Map<String, String>> resolveHiddenInputs) {
@@ -135,4 +108,39 @@ public final class DefaultOneTimeTokenSubmitPageGeneratingFilter extends OncePer
 		this.loginProcessingUrl = loginProcessingUrl;
 	}
 
+	private static final String ONE_TIME_TOKEN_SUBMIT_PAGE_TEMPLATE = """
+			<!DOCTYPE html>
+			<html lang="en">
+			  <head>
+			    <title>One-Time Token Login</title>
+			    <meta charset="utf-8"/>
+			    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
+			    <meta http-equiv="Content-Security-Policy" content="script-src 'sha256-oZhLbc2kO8b8oaYLrUc7uye1MgVKMyLtPqWR4WtKF+c='"/>
+			{{cssStyle}}
+			  </head>
+			  <body>
+			    <noscript>
+			      <p>
+			        <strong>Note:</strong> Since your browser does not support JavaScript, you must press the Sign In button once to proceed.
+			      </p>
+			    </noscript>
+			    <div class="container">
+			      <form class="login-form" action="{{loginProcessingUrl}}" method="post">
+			        <h2>Please input the token</h2>
+			        <p>
+			          <label for="token" class="screenreader">Token</label>
+			          <input type="text" id="token" name="token" value="{{tokenValue}}" placeholder="Token" required="true" autofocus="autofocus"/>
+			        </p>
+			        <button class="primary" type="submit">Sign in</button>
+			{{hiddenInputs}}
+			      </form>
+			    </div>
+			  </body>
+			</html>
+			""";
+
+	private static final String HIDDEN_HTML_INPUT_TEMPLATE = """
+			<input name="{{name}}" type="hidden" value="{{value}}" />
+			""";
+
 }

+ 167 - 2
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 @@ class DefaultOneTimeTokenSubmitPageGeneratingFilterTests {
 		this.filter.setLoginProcessingUrl("/login/another");
 		this.filter.doFilterInternal(this.request, this.response, this.filterChain);
 		String response = this.response.getContentAsString();
-		assertThat(response).contains(
-				"<form class=\"login-form\" action=\"/login/another\" method=\"post\">\t<h2>Please input the token</h2>");
+		assertThat(response).contains("<form class=\"login-form\" action=\"/login/another\" method=\"post\">");
 	}
 
 	@Test
@@ -85,4 +86,168 @@ class DefaultOneTimeTokenSubmitPageGeneratingFilterTests {
 				"<input type=\"text\" id=\"token\" name=\"token\" value=\"this&lt;&gt;!@#&quot;\" placeholder=\"Token\" required=\"true\" autofocus=\"autofocus\"/>");
 	}
 
+	@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(
+				"""
+						<!DOCTYPE html>
+						<html lang="en">
+						  <head>
+						    <title>One-Time Token Login</title>
+						    <meta charset="utf-8"/>
+						    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
+						    <meta http-equiv="Content-Security-Policy" content="script-src 'sha256-oZhLbc2kO8b8oaYLrUc7uye1MgVKMyLtPqWR4WtKF+c='"/>
+						    <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
+						    h2 {
+						      margin-top: 0;
+						      margin-bottom: 0.5rem;
+						      font-size: 2rem;
+						      font-weight: 500;
+						      line-height: 2rem;
+						    }
+						   \s
+						    .content {
+						      margin-right: auto;
+						      margin-left: auto;
+						      padding-right: 15px;
+						      padding-left: 15px;
+						      width: 100%;
+						      box-sizing: border-box;
+						    }
+						   \s
+						    @media (min-width: 800px) {
+						      .content {
+						        max-width: 760px;
+						      }
+						    }
+						   \s
+						    /* Components */
+						    a,
+						    a:visited {
+						      text-decoration: none;
+						      color: #06f;
+						    }
+						   \s
+						    a:hover {
+						      text-decoration: underline;
+						      color: #003c97;
+						    }
+						   \s
+						    input[type="text"],
+						    input[type="password"] {
+						      height: auto;
+						      width: 100%;
+						      font-size: 1rem;
+						      padding: 0.5rem;
+						      box-sizing: border-box;
+						    }
+						   \s
+						    button {
+						      padding: 0.5rem 1rem;
+						      font-size: 1.25rem;
+						      line-height: 1.5;
+						      border: none;
+						      border-radius: 0.1rem;
+						      width: 100%;
+						    }
+						   \s
+						    button.primary {
+						      color: #fff;
+						      background-color: #06f;
+						    }
+						   \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
+						    .alert.alert-danger {
+						      color: #6b1922;
+						      background-color: #f7d5d7;
+						      border-color: #eab6bb;
+						    }
+						   \s
+						    .alert.alert-success {
+						      color: #145222;
+						      background-color: #d1f0d9;
+						      border-color: #c2ebcb;
+						    }
+						   \s
+						    .screenreader {
+						      position: absolute;
+						      clip: rect(0 0 0 0);
+						      height: 1px;
+						      width: 1px;
+						      padding: 0;
+						      border: 0;
+						      overflow: hidden;
+						    }
+						   \s
+						    table {
+						      width: 100%;
+						      max-width: 100%;
+						      margin-bottom: 2rem;
+						    }
+						   \s
+						    .table-striped tr:nth-of-type(2n + 1) {
+						      background-color: #e1e1e1;
+						    }
+						   \s
+						    td {
+						      padding: 0.75rem;
+						      vertical-align: top;
+						    }
+						   \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>
+						    <noscript>
+						      <p>
+						        <strong>Note:</strong> Since your browser does not support JavaScript, you must press the Sign In button once to proceed.
+						      </p>
+						    </noscript>
+						    <div class="container">
+						      <form class="login-form" action="/login/another" method="post">
+						        <h2>Please input the token</h2>
+						        <p>
+						          <label for="token" class="screenreader">Token</label>
+						          <input type="text" id="token" name="token" value="this&lt;&gt;!@#&quot;" placeholder="Token" required="true" autofocus="autofocus"/>
+						        </p>
+						        <button class="primary" type="submit">Sign in</button>
+						<input name="_csrf" type="hidden" value="csrf-token-value" />
+						      </form>
+						    </div>
+						  </body>
+						</html>
+						""");
+	}
+
 }