Skip to content

Commit ef31ae1

Browse files
Kehrlannrwinch
authored andcommitted
Render One Time Token UIs using lightweight templates
1 parent a642a1b commit ef31ae1

File tree

2 files changed

+222
-49
lines changed

2 files changed

+222
-49
lines changed

web/src/main/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilter.java

+55-47
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.util.Collections;
2222
import java.util.Map;
2323
import java.util.function.Function;
24+
import java.util.stream.Collectors;
2425

2526
import jakarta.servlet.FilterChain;
2627
import jakarta.servlet.ServletException;
@@ -33,7 +34,6 @@
3334
import org.springframework.util.Assert;
3435
import org.springframework.util.StringUtils;
3536
import org.springframework.web.filter.OncePerRequestFilter;
36-
import org.springframework.web.util.HtmlUtils;
3737

3838
/**
3939
* Creates a default one-time token submit page. If the request contains a {@code token}
@@ -65,54 +65,27 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
6565

6666
private String generateHtml(HttpServletRequest request) {
6767
String token = request.getParameter("token");
68-
String inputValue = StringUtils.hasText(token) ? HtmlUtils.htmlEscape(token) : "";
69-
String input = "<input type=\"text\" id=\"token\" name=\"token\" value=\"" + inputValue + "\""
70-
+ " placeholder=\"Token\" required=\"true\" autofocus=\"autofocus\"/>";
71-
return """
72-
<!DOCTYPE html>
73-
<html lang="en">
74-
<head>
75-
<title>One-Time Token Login</title>
76-
<meta charset="utf-8"/>
77-
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
78-
<meta http-equiv="Content-Security-Policy" content="script-src 'sha256-oZhLbc2kO8b8oaYLrUc7uye1MgVKMyLtPqWR4WtKF+c='"/>
79-
"""
80-
+ CssUtils.getCssStyleBlock().indent(4)
81-
+ """
82-
</head>
83-
<body>
84-
<noscript>
85-
<p>
86-
<strong>Note:</strong> Since your browser does not support JavaScript, you must press the Sign In button once to proceed.
87-
</p>
88-
</noscript>
89-
<div class="container">
90-
"""
91-
+ "<form class=\"login-form\" action=\"" + this.loginProcessingUrl + "\" method=\"post\">" + """
92-
<h2>Please input the token</h2>
93-
<p>
94-
<label for="token" class="screenreader">Token</label>
95-
""" + input + """
96-
</p>
97-
<button class="primary" type="submit">Sign in</button>
98-
""" + renderHiddenInputs(request) + """
99-
</form>
100-
</div>
101-
</body>
102-
</html>
103-
""";
68+
String tokenValue = StringUtils.hasText(token) ? token : "";
69+
70+
String hiddenInputs = this.resolveHiddenInputs.apply(request)
71+
.entrySet()
72+
.stream()
73+
.map((inputKeyValue) -> renderHiddenInput(inputKeyValue.getKey(), inputKeyValue.getValue()))
74+
.collect(Collectors.joining("\n"));
75+
76+
return HtmlTemplates.fromTemplate(ONE_TIME_TOKEN_SUBMIT_PAGE_TEMPLATE)
77+
.withRawHtml("cssStyle", CssUtils.getCssStyleBlock().indent(4))
78+
.withValue("tokenValue", tokenValue)
79+
.withValue("loginProcessingUrl", this.loginProcessingUrl)
80+
.withRawHtml("hiddenInputs", hiddenInputs)
81+
.render();
10482
}
10583

106-
private String renderHiddenInputs(HttpServletRequest request) {
107-
StringBuilder sb = new StringBuilder();
108-
for (Map.Entry<String, String> input : this.resolveHiddenInputs.apply(request).entrySet()) {
109-
sb.append("<input name=\"");
110-
sb.append(input.getKey());
111-
sb.append("\" type=\"hidden\" value=\"");
112-
sb.append(input.getValue());
113-
sb.append("\" />\n");
114-
}
115-
return sb.toString();
84+
private String renderHiddenInput(String name, String value) {
85+
return HtmlTemplates.fromTemplate(HIDDEN_HTML_INPUT_TEMPLATE)
86+
.withValue("name", name)
87+
.withValue("value", value)
88+
.render();
11689
}
11790

11891
public void setResolveHiddenInputs(Function<HttpServletRequest, Map<String, String>> resolveHiddenInputs) {
@@ -135,4 +108,39 @@ public void setLoginProcessingUrl(String loginProcessingUrl) {
135108
this.loginProcessingUrl = loginProcessingUrl;
136109
}
137110

111+
private static final String ONE_TIME_TOKEN_SUBMIT_PAGE_TEMPLATE = """
112+
<!DOCTYPE html>
113+
<html lang="en">
114+
<head>
115+
<title>One-Time Token Login</title>
116+
<meta charset="utf-8"/>
117+
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
118+
<meta http-equiv="Content-Security-Policy" content="script-src 'sha256-oZhLbc2kO8b8oaYLrUc7uye1MgVKMyLtPqWR4WtKF+c='"/>
119+
{{cssStyle}}
120+
</head>
121+
<body>
122+
<noscript>
123+
<p>
124+
<strong>Note:</strong> Since your browser does not support JavaScript, you must press the Sign In button once to proceed.
125+
</p>
126+
</noscript>
127+
<div class="container">
128+
<form class="login-form" action="{{loginProcessingUrl}}" method="post">
129+
<h2>Please input the token</h2>
130+
<p>
131+
<label for="token" class="screenreader">Token</label>
132+
<input type="text" id="token" name="token" value="{{tokenValue}}" placeholder="Token" required="true" autofocus="autofocus"/>
133+
</p>
134+
<button class="primary" type="submit">Sign in</button>
135+
{{hiddenInputs}}
136+
</form>
137+
</div>
138+
</body>
139+
</html>
140+
""";
141+
142+
private static final String HIDDEN_HTML_INPUT_TEMPLATE = """
143+
<input name="{{name}}" type="hidden" value="{{value}}" />
144+
""";
145+
138146
}

web/src/test/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilterTests.java

+167-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
package org.springframework.security.web.authentication.ui;
1818

19+
import java.util.Map;
20+
1921
import org.junit.jupiter.api.BeforeEach;
2022
import org.junit.jupiter.api.Test;
2123

@@ -72,8 +74,7 @@ void setLoginProcessingUrlThenUseItForFormAction() throws Exception {
7274
this.filter.setLoginProcessingUrl("/login/another");
7375
this.filter.doFilterInternal(this.request, this.response, this.filterChain);
7476
String response = this.response.getContentAsString();
75-
assertThat(response).contains(
76-
"<form class=\"login-form\" action=\"/login/another\" method=\"post\">\t<h2>Please input the token</h2>");
77+
assertThat(response).contains("<form class=\"login-form\" action=\"/login/another\" method=\"post\">");
7778
}
7879

7980
@Test
@@ -85,4 +86,168 @@ void filterWhenTokenQueryParamUsesSpecialCharactersThenValueIsEscaped() throws E
8586
"<input type=\"text\" id=\"token\" name=\"token\" value=\"this&lt;&gt;!@#&quot;\" placeholder=\"Token\" required=\"true\" autofocus=\"autofocus\"/>");
8687
}
8788

89+
@Test
90+
void filterThenRenders() throws Exception {
91+
this.request.setParameter("token", "this<>!@#\"");
92+
this.filter.setLoginProcessingUrl("/login/another");
93+
this.filter.setResolveHiddenInputs((request) -> Map.of("_csrf", "csrf-token-value"));
94+
this.filter.doFilterInternal(this.request, this.response, this.filterChain);
95+
String response = this.response.getContentAsString();
96+
assertThat(response).isEqualTo(
97+
"""
98+
<!DOCTYPE html>
99+
<html lang="en">
100+
<head>
101+
<title>One-Time Token Login</title>
102+
<meta charset="utf-8"/>
103+
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
104+
<meta http-equiv="Content-Security-Policy" content="script-src 'sha256-oZhLbc2kO8b8oaYLrUc7uye1MgVKMyLtPqWR4WtKF+c='"/>
105+
<style>
106+
/* General layout */
107+
body {
108+
font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
109+
background-color: #eee;
110+
padding: 40px 0;
111+
margin: 0;
112+
line-height: 1.5;
113+
}
114+
\s
115+
h2 {
116+
margin-top: 0;
117+
margin-bottom: 0.5rem;
118+
font-size: 2rem;
119+
font-weight: 500;
120+
line-height: 2rem;
121+
}
122+
\s
123+
.content {
124+
margin-right: auto;
125+
margin-left: auto;
126+
padding-right: 15px;
127+
padding-left: 15px;
128+
width: 100%;
129+
box-sizing: border-box;
130+
}
131+
\s
132+
@media (min-width: 800px) {
133+
.content {
134+
max-width: 760px;
135+
}
136+
}
137+
\s
138+
/* Components */
139+
a,
140+
a:visited {
141+
text-decoration: none;
142+
color: #06f;
143+
}
144+
\s
145+
a:hover {
146+
text-decoration: underline;
147+
color: #003c97;
148+
}
149+
\s
150+
input[type="text"],
151+
input[type="password"] {
152+
height: auto;
153+
width: 100%;
154+
font-size: 1rem;
155+
padding: 0.5rem;
156+
box-sizing: border-box;
157+
}
158+
\s
159+
button {
160+
padding: 0.5rem 1rem;
161+
font-size: 1.25rem;
162+
line-height: 1.5;
163+
border: none;
164+
border-radius: 0.1rem;
165+
width: 100%;
166+
}
167+
\s
168+
button.primary {
169+
color: #fff;
170+
background-color: #06f;
171+
}
172+
\s
173+
.alert {
174+
padding: 0.75rem 1rem;
175+
margin-bottom: 1rem;
176+
line-height: 1.5;
177+
border-radius: 0.1rem;
178+
width: 100%;
179+
box-sizing: border-box;
180+
border-width: 1px;
181+
border-style: solid;
182+
}
183+
\s
184+
.alert.alert-danger {
185+
color: #6b1922;
186+
background-color: #f7d5d7;
187+
border-color: #eab6bb;
188+
}
189+
\s
190+
.alert.alert-success {
191+
color: #145222;
192+
background-color: #d1f0d9;
193+
border-color: #c2ebcb;
194+
}
195+
\s
196+
.screenreader {
197+
position: absolute;
198+
clip: rect(0 0 0 0);
199+
height: 1px;
200+
width: 1px;
201+
padding: 0;
202+
border: 0;
203+
overflow: hidden;
204+
}
205+
\s
206+
table {
207+
width: 100%;
208+
max-width: 100%;
209+
margin-bottom: 2rem;
210+
}
211+
\s
212+
.table-striped tr:nth-of-type(2n + 1) {
213+
background-color: #e1e1e1;
214+
}
215+
\s
216+
td {
217+
padding: 0.75rem;
218+
vertical-align: top;
219+
}
220+
\s
221+
/* Login / logout layouts */
222+
.login-form,
223+
.logout-form {
224+
max-width: 340px;
225+
padding: 0 15px 15px 15px;
226+
margin: 0 auto 2rem auto;
227+
box-sizing: border-box;
228+
}
229+
</style>
230+
</head>
231+
<body>
232+
<noscript>
233+
<p>
234+
<strong>Note:</strong> Since your browser does not support JavaScript, you must press the Sign In button once to proceed.
235+
</p>
236+
</noscript>
237+
<div class="container">
238+
<form class="login-form" action="/login/another" method="post">
239+
<h2>Please input the token</h2>
240+
<p>
241+
<label for="token" class="screenreader">Token</label>
242+
<input type="text" id="token" name="token" value="this&lt;&gt;!@#&quot;" placeholder="Token" required="true" autofocus="autofocus"/>
243+
</p>
244+
<button class="primary" type="submit">Sign in</button>
245+
<input name="_csrf" type="hidden" value="csrf-token-value" />
246+
</form>
247+
</div>
248+
</body>
249+
</html>
250+
""");
251+
}
252+
88253
}

0 commit comments

Comments
 (0)