Skip to content

Commit a642a1b

Browse files
Kehrlannrwinch
authored andcommitted
Render reactive default UIs using lightweight templates
1 parent 8d47906 commit a642a1b

File tree

5 files changed

+549
-81
lines changed

5 files changed

+549
-81
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web.server.ui;
18+
19+
import java.util.HashMap;
20+
import java.util.Map;
21+
import java.util.regex.Pattern;
22+
import java.util.stream.Collectors;
23+
24+
import org.springframework.util.StringUtils;
25+
import org.springframework.web.util.HtmlUtils;
26+
27+
/**
28+
* Render HTML templates using string substitution. Intended for internal use. Variables
29+
* can be templated using double curly-braces: {@code {{name}}}.
30+
*
31+
* @author Daniel Garnier-Moiroux
32+
* @since 6.4
33+
* @see org.springframework.security.web.authentication.ui.HtmlTemplates
34+
*/
35+
final class HtmlTemplates {
36+
37+
private HtmlTemplates() {
38+
}
39+
40+
static Builder fromTemplate(String template) {
41+
return new Builder(template);
42+
}
43+
44+
static final class Builder {
45+
46+
private final String template;
47+
48+
private final Map<String, String> values = new HashMap<>();
49+
50+
private Builder(String template) {
51+
this.template = template;
52+
}
53+
54+
/**
55+
* HTML-escape, and inject value {@code value} in every {@code {{key}}}
56+
* placeholder.
57+
* @param key the placeholder name
58+
* @param value the value to inject
59+
* @return this instance for further templating
60+
*/
61+
Builder withValue(String key, String value) {
62+
this.values.put(key, HtmlUtils.htmlEscape(value));
63+
return this;
64+
}
65+
66+
/**
67+
* Inject value {@code value} in every {@code {{key}}} placeholder without
68+
* HTML-escaping. Useful for injecting "sub-templates".
69+
* @param key the placeholder name
70+
* @param value the value to inject
71+
* @return this instance for further templating
72+
*/
73+
Builder withRawHtml(String key, String value) {
74+
if (!value.isEmpty() && value.charAt(value.length() - 1) == '\n') {
75+
value = value.substring(0, value.length() - 1);
76+
}
77+
this.values.put(key, value);
78+
return this;
79+
}
80+
81+
/**
82+
* Render the template. All placeholders MUST have a corresponding value. If a
83+
* placeholder does not have a corresponding value, throws
84+
* {@link IllegalStateException}.
85+
* @return the rendered template
86+
*/
87+
String render() {
88+
String template = this.template;
89+
for (String key : this.values.keySet()) {
90+
String pattern = Pattern.quote("{{" + key + "}}");
91+
template = template.replaceAll(pattern, this.values.get(key));
92+
}
93+
94+
String unusedPlaceholders = Pattern.compile("\\{\\{([a-zA-Z0-9]+)}}")
95+
.matcher(template)
96+
.results()
97+
.map((result) -> result.group(1))
98+
.collect(Collectors.joining(", "));
99+
if (StringUtils.hasLength(unusedPlaceholders)) {
100+
throw new IllegalStateException("Unused placeholders in template: [%s]".formatted(unusedPlaceholders));
101+
}
102+
103+
return template;
104+
}
105+
106+
}
107+
108+
}

web/src/main/java/org/springframework/security/web/server/ui/LoginPageGeneratingWebFilter.java

+87-57
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.nio.charset.Charset;
2020
import java.util.HashMap;
2121
import java.util.Map;
22+
import java.util.stream.Collectors;
2223

2324
import reactor.core.publisher.Mono;
2425

@@ -37,7 +38,6 @@
3738
import org.springframework.web.server.ServerWebExchange;
3839
import org.springframework.web.server.WebFilter;
3940
import org.springframework.web.server.WebFilterChain;
40-
import org.springframework.web.util.HtmlUtils;
4141

4242
/**
4343
* Generates a default log in page used for authenticating users.
@@ -89,80 +89,61 @@ private Mono<DataBuffer> createBuffer(ServerWebExchange exchange) {
8989
private byte[] createPage(ServerWebExchange exchange, String csrfTokenHtmlInput) {
9090
MultiValueMap<String, String> queryParams = exchange.getRequest().getQueryParams();
9191
String contextPath = exchange.getRequest().getPath().contextPath().value();
92-
StringBuilder page = new StringBuilder();
93-
page.append("<!DOCTYPE html>\n");
94-
page.append("<html lang=\"en\">\n");
95-
page.append(" <head>\n");
96-
page.append(" <meta charset=\"utf-8\">\n");
97-
page.append(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n");
98-
page.append(" <meta name=\"description\" content=\"\">\n");
99-
page.append(" <meta name=\"author\" content=\"\">\n");
100-
page.append(" <title>Please sign in</title>\n");
101-
page.append(CssUtils.getCssStyleBlock().indent(4));
102-
page.append(" </head>\n");
103-
page.append(" <body>\n");
104-
page.append(" <div class=\"content\">\n");
105-
page.append(formLogin(queryParams, contextPath, csrfTokenHtmlInput));
106-
page.append(oauth2LoginLinks(queryParams, contextPath, this.oauth2AuthenticationUrlToClientName));
107-
page.append(" </div>\n");
108-
page.append(" </body>\n");
109-
page.append("</html>");
110-
return page.toString().getBytes(Charset.defaultCharset());
92+
93+
return HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE)
94+
.withRawHtml("cssStyle", CssUtils.getCssStyleBlock().indent(4))
95+
.withRawHtml("formLogin", formLogin(queryParams, contextPath, csrfTokenHtmlInput))
96+
.withRawHtml("oauth2Login", oauth2Login(queryParams, contextPath, this.oauth2AuthenticationUrlToClientName))
97+
.render()
98+
.getBytes(Charset.defaultCharset());
11199
}
112100

113101
private String formLogin(MultiValueMap<String, String> queryParams, String contextPath, String csrfTokenHtmlInput) {
114102
if (!this.formLoginEnabled) {
115103
return "";
116104
}
105+
117106
boolean isError = queryParams.containsKey("error");
118107
boolean isLogoutSuccess = queryParams.containsKey("logout");
119-
StringBuilder page = new StringBuilder();
120-
page.append(" <form class=\"login-form\" method=\"post\" action=\"" + contextPath + "/login\">\n");
121-
page.append(" <h2>Please sign in</h2>\n");
122-
page.append(createError(isError));
123-
page.append(createLogoutSuccess(isLogoutSuccess));
124-
page.append(" <p>\n");
125-
page.append(" <label for=\"username\" class=\"screenreader\">Username</label>\n");
126-
page.append(" <input type=\"text\" id=\"username\" name=\"username\" "
127-
+ "placeholder=\"Username\" required autofocus>\n");
128-
page.append(" </p>\n" + " <p>\n");
129-
page.append(" <label for=\"password\" class=\"screenreader\">Password</label>\n");
130-
page.append(" <input type=\"password\" id=\"password\" name=\"password\" "
131-
+ "placeholder=\"Password\" required>\n");
132-
page.append(" </p>\n");
133-
page.append(csrfTokenHtmlInput);
134-
page.append(" <button class=\"primary\" type=\"submit\">Sign in</button>\n");
135-
page.append(" </form>\n");
136-
return page.toString();
108+
109+
return HtmlTemplates.fromTemplate(LOGIN_FORM_TEMPLATE)
110+
.withValue("loginUrl", contextPath + "/login")
111+
.withRawHtml("errorMessage", createError(isError))
112+
.withRawHtml("logoutMessage", createLogoutSuccess(isLogoutSuccess))
113+
.withRawHtml("csrf", csrfTokenHtmlInput)
114+
.render();
137115
}
138116

139-
private static String oauth2LoginLinks(MultiValueMap<String, String> queryParams, String contextPath,
117+
private static String oauth2Login(MultiValueMap<String, String> queryParams, String contextPath,
140118
Map<String, String> oauth2AuthenticationUrlToClientName) {
141119
if (oauth2AuthenticationUrlToClientName.isEmpty()) {
142120
return "";
143121
}
144122
boolean isError = queryParams.containsKey("error");
145-
StringBuilder sb = new StringBuilder();
146-
sb.append("<div class=\"content\"><h2>Login with OAuth 2.0</h2>");
147-
sb.append(createError(isError));
148-
sb.append("<table class=\"table table-striped\">\n");
149-
for (Map.Entry<String, String> clientAuthenticationUrlToClientName : oauth2AuthenticationUrlToClientName
150-
.entrySet()) {
151-
sb.append(" <tr><td>");
152-
String url = clientAuthenticationUrlToClientName.getKey();
153-
sb.append("<a href=\"").append(contextPath).append(url).append("\">");
154-
String clientName = HtmlUtils.htmlEscape(clientAuthenticationUrlToClientName.getValue());
155-
sb.append(clientName);
156-
sb.append("</a>");
157-
sb.append("</td></tr>\n");
158-
}
159-
sb.append("</table></div>\n");
160-
return sb.toString();
123+
124+
String oauth2Rows = oauth2AuthenticationUrlToClientName.entrySet()
125+
.stream()
126+
.map((urlToName) -> oauth2LoginLink(contextPath, urlToName.getKey(), urlToName.getValue()))
127+
.collect(Collectors.joining("\n"))
128+
.indent(2);
129+
return HtmlTemplates.fromTemplate(OAUTH2_LOGIN_TEMPLATE)
130+
.withRawHtml("errorMessage", createError(isError))
131+
.withRawHtml("oauth2Rows", oauth2Rows)
132+
.render();
133+
}
134+
135+
private static String oauth2LoginLink(String contextPath, String url, String clientName) {
136+
return HtmlTemplates.fromTemplate(OAUTH2_ROW_TEMPLATE)
137+
.withValue("url", contextPath + url)
138+
.withValue("clientName", clientName)
139+
.render();
161140
}
162141

163142
private static String csrfToken(CsrfToken token) {
164-
return " <input type=\"hidden\" name=\"" + token.getParameterName() + "\" value=\"" + token.getToken()
165-
+ "\">\n";
143+
return HtmlTemplates.fromTemplate(CSRF_INPUT_TEMPLATE)
144+
.withValue("name", token.getParameterName())
145+
.withValue("value", token.getToken())
146+
.render();
166147
}
167148

168149
private static String createError(boolean isError) {
@@ -174,4 +155,53 @@ private static String createLogoutSuccess(boolean isLogoutSuccess) {
174155
: "";
175156
}
176157

158+
private static final String LOGIN_PAGE_TEMPLATE = """
159+
<!DOCTYPE html>
160+
<html lang="en">
161+
<head>
162+
<meta charset="utf-8">
163+
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
164+
<meta name="description" content="">
165+
<meta name="author" content="">
166+
<title>Please sign in</title>
167+
{{cssStyle}}
168+
</head>
169+
<body>
170+
<div class="content">
171+
{{formLogin}}
172+
{{oauth2Login}}
173+
</div>
174+
</body>
175+
</html>""";
176+
177+
private static final String LOGIN_FORM_TEMPLATE = """
178+
<form class="login-form" method="post" action="{{loginUrl}}">
179+
<h2>Please sign in</h2>
180+
{{errorMessage}}{{logoutMessage}}
181+
<p>
182+
<label for="username" class="screenreader">Username</label>
183+
<input type="text" id="username" name="username" placeholder="Username" required autofocus>
184+
</p>
185+
<p>
186+
<label for="password" class="screenreader">Password</label>
187+
<input type="password" id="password" name="password" placeholder="Password" required>
188+
</p>
189+
{{csrf}}
190+
<button type="submit" class="primary">Sign in</button>
191+
</form>""";
192+
193+
private static final String CSRF_INPUT_TEMPLATE = """
194+
<input name="{{name}}" type="hidden" value="{{value}}" />
195+
""";
196+
197+
private static final String OAUTH2_LOGIN_TEMPLATE = """
198+
<h2>Login with OAuth 2.0</h2>
199+
{{errorMessage}}
200+
<table class="table table-striped">
201+
{{oauth2Rows}}
202+
</table>""";
203+
204+
private static final String OAUTH2_ROW_TEMPLATE = """
205+
<tr><td><a href="{{url}}">{{clientName}}</a></td></tr>""";
206+
177207
}

web/src/main/java/org/springframework/security/web/server/ui/LogoutPageGeneratingWebFilter.java

+36-24
Original file line numberDiff line numberDiff line change
@@ -70,33 +70,45 @@ private Mono<DataBuffer> createBuffer(ServerWebExchange exchange) {
7070
}
7171

7272
private static byte[] createPage(String csrfTokenHtmlInput, String contextPath) {
73-
StringBuilder page = new StringBuilder();
74-
page.append("<!DOCTYPE html>\n");
75-
page.append("<html lang=\"en\">\n");
76-
page.append(" <head>\n");
77-
page.append(" <meta charset=\"utf-8\">\n");
78-
page.append(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n");
79-
page.append(" <meta name=\"description\" content=\"\">\n");
80-
page.append(" <meta name=\"author\" content=\"\">\n");
81-
page.append(" <title>Confirm Log Out?</title>\n");
82-
page.append(CssUtils.getCssStyleBlock().indent(4));
83-
page.append(" </head>\n");
84-
page.append(" <body>\n");
85-
page.append(" <div class=\"content\">\n");
86-
page.append(" <form class=\"logout-form\" method=\"post\" action=\"" + contextPath + "/logout\">\n");
87-
page.append(" <h2>Are you sure you want to log out?</h2>\n");
88-
page.append(csrfTokenHtmlInput);
89-
page.append(" <button class=\"primary\" type=\"submit\">Log Out</button>\n");
90-
page.append(" </form>\n");
91-
page.append(" </div>\n");
92-
page.append(" </body>\n");
93-
page.append("</html>");
94-
return page.toString().getBytes(Charset.defaultCharset());
73+
return HtmlTemplates.fromTemplate(LOGOUT_PAGE_TEMPLATE)
74+
.withRawHtml("cssStyle", CssUtils.getCssStyleBlock().indent(4))
75+
.withValue("contextPath", contextPath)
76+
.withRawHtml("csrf", csrfTokenHtmlInput.indent(8))
77+
.render()
78+
.getBytes(Charset.defaultCharset());
9579
}
9680

9781
private static String csrfToken(CsrfToken token) {
98-
return " <input type=\"hidden\" name=\"" + token.getParameterName() + "\" value=\"" + token.getToken()
99-
+ "\">\n";
82+
return HtmlTemplates.fromTemplate(CSRF_INPUT_TEMPLATE)
83+
.withValue("name", token.getParameterName())
84+
.withValue("value", token.getToken())
85+
.render();
10086
}
10187

88+
private static final String LOGOUT_PAGE_TEMPLATE = """
89+
<!DOCTYPE html>
90+
<html lang="en">
91+
<head>
92+
<meta charset="utf-8">
93+
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
94+
<meta name="description" content="">
95+
<meta name="author" content="">
96+
<title>Confirm Log Out?</title>
97+
{{cssStyle}}
98+
</head>
99+
<body>
100+
<div class="content">
101+
<form class="logout-form" method="post" action="{{contextPath}}/logout">
102+
<h2>Are you sure you want to log out?</h2>
103+
{{csrf}}
104+
<button class="primary" type="submit">Log Out</button>
105+
</form>
106+
</div>
107+
</body>
108+
</html>""";
109+
110+
private static final String CSRF_INPUT_TEMPLATE = """
111+
<input name="{{name}}" type="hidden" value="{{value}}" />
112+
""";
113+
102114
}

0 commit comments

Comments
 (0)