Skip to content

Commit 5986d38

Browse files
committed
Support Showing One Part of Login Page
Closes gh-17901
1 parent 04ef171 commit 5986d38

File tree

7 files changed

+153
-48
lines changed

7 files changed

+153
-48
lines changed

config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception {
411411
UserDetails user = PasswordEncodedUser.user();
412412
this.mockMvc.perform(get("/profile").with(user(user)))
413413
.andExpect(status().is3xxRedirection())
414-
.andExpect(redirectedUrl("http://localhost/login"));
414+
.andExpect(redirectedUrl("http://localhost/login?authority=FACTOR_PASSWORD"));
415415
this.mockMvc
416416
.perform(post("/ott/generate").param("username", "rod")
417417
.with(user(user))
@@ -427,11 +427,11 @@ void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception {
427427
user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_OTT").build();
428428
this.mockMvc.perform(get("/profile").with(user(user)))
429429
.andExpect(status().is3xxRedirection())
430-
.andExpect(redirectedUrl("http://localhost/login"));
430+
.andExpect(redirectedUrl("http://localhost/login?authority=FACTOR_PASSWORD"));
431431
user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_PASSWORD").build();
432432
this.mockMvc.perform(get("/profile").with(user(user)))
433433
.andExpect(status().is3xxRedirection())
434-
.andExpect(redirectedUrl("http://localhost/login"));
434+
.andExpect(redirectedUrl("http://localhost/login?authority=FACTOR_OTT"));
435435
user = PasswordEncodedUser.withUserDetails(user)
436436
.authorities("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT")
437437
.build();
@@ -447,7 +447,7 @@ void requestWhenUnauthenticatedX509ThenRequiresTwoSteps() throws Exception {
447447
this.mockMvc.perform(get("/login")).andExpect(status().isOk());
448448
this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.x509("rod.cer")))
449449
.andExpect(status().is3xxRedirection())
450-
.andExpect(redirectedUrl("http://localhost/login"));
450+
.andExpect(redirectedUrl("http://localhost/login?authority=FACTOR_PASSWORD"));
451451
this.mockMvc
452452
.perform(post("/login").param("username", "rod")
453453
.param("password", "password")

core/src/main/java/org/springframework/security/core/GrantedAuthority.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
*/
3232
public interface GrantedAuthority extends Serializable {
3333

34+
String MISSING_AUTHORITIES_ATTRIBUTE = GrantedAuthority.class + ".missingAuthorities";
35+
3436
/**
3537
* If the <code>GrantedAuthority</code> can be represented as a <code>String</code>
3638
* and that <code>String</code> is sufficient in precision to be relied upon for an

web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java

Lines changed: 17 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
package org.springframework.security.web.access;
1818

1919
import java.io.IOException;
20-
import java.util.ArrayList;
2120
import java.util.Collection;
2221
import java.util.LinkedHashMap;
2322
import java.util.List;
@@ -41,7 +40,6 @@
4140
import org.springframework.security.web.util.ThrowableAnalyzer;
4241
import org.springframework.security.web.util.matcher.AnyRequestMatcher;
4342
import org.springframework.security.web.util.matcher.RequestMatcher;
44-
import org.springframework.security.web.util.matcher.RequestMatcherEntry;
4543

4644
public final class DelegatingMissingAuthorityAccessDeniedHandler implements AccessDeniedHandler {
4745

@@ -61,14 +59,19 @@ private DelegatingMissingAuthorityAccessDeniedHandler(Map<String, Authentication
6159
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException denied)
6260
throws IOException, ServletException {
6361
Collection<GrantedAuthority> authorities = missingAuthorities(denied);
64-
AuthenticationEntryPoint entryPoint = entryPoint(authorities);
65-
if (entryPoint == null) {
66-
this.defaultAccessDeniedHandler.handle(request, response, denied);
62+
for (GrantedAuthority needed : authorities) {
63+
AuthenticationEntryPoint entryPoint = this.entryPoints.get(needed.getAuthority());
64+
if (entryPoint == null) {
65+
continue;
66+
}
67+
this.requestCache.saveRequest(request, response);
68+
request.setAttribute(GrantedAuthority.MISSING_AUTHORITIES_ATTRIBUTE, List.of(needed));
69+
String message = String.format("Missing Authorities %s", List.of(needed));
70+
AuthenticationException ex = new InsufficientAuthenticationException(message, denied);
71+
entryPoint.commence(request, response, ex);
6772
return;
6873
}
69-
this.requestCache.saveRequest(request, response);
70-
AuthenticationException ex = new InsufficientAuthenticationException("missing authorities", denied);
71-
entryPoint.commence(request, response, ex);
74+
this.defaultAccessDeniedHandler.handle(request, response, denied);
7275
}
7376

7477
public void setDefaultAccessDeniedHandler(AccessDeniedHandler defaultAccessDeniedHandler) {
@@ -79,17 +82,6 @@ public void setRequestCache(RequestCache requestCache) {
7982
this.requestCache = requestCache;
8083
}
8184

82-
private @Nullable AuthenticationEntryPoint entryPoint(Collection<GrantedAuthority> authorities) {
83-
for (GrantedAuthority needed : authorities) {
84-
AuthenticationEntryPoint entryPoint = this.entryPoints.get(needed.getAuthority());
85-
if (entryPoint == null) {
86-
continue;
87-
}
88-
return entryPoint;
89-
}
90-
return null;
91-
}
92-
9385
private Collection<GrantedAuthority> missingAuthorities(AccessDeniedException ex) {
9486
AuthorizationDeniedException denied = findAuthorizationDeniedException(ex);
9587
if (denied == null) {
@@ -116,7 +108,7 @@ public static Builder builder() {
116108

117109
public static final class Builder {
118110

119-
private final Map<String, List<RequestMatcherEntry<AuthenticationEntryPoint>>> entryPointByRequestMatcherByAuthority = new LinkedHashMap<>();
111+
private final Map<String, DelegatingAuthenticationEntryPoint.Builder> entryPointByRequestMatcherByAuthority = new LinkedHashMap<>();
120112

121113
private Builder() {
122114

@@ -125,9 +117,9 @@ private Builder() {
125117
Builder entryPointFor(RequestMatcher requestMatcher, AuthenticationEntryPoint entryPoint,
126118
String... authorities) {
127119
for (String authority : authorities) {
128-
List<RequestMatcherEntry<AuthenticationEntryPoint>> entryPointByRequestMatcher = this.entryPointByRequestMatcherByAuthority
129-
.computeIfAbsent(authority, (k) -> new ArrayList<>());
130-
entryPointByRequestMatcher.add(new RequestMatcherEntry<>(requestMatcher, entryPoint));
120+
DelegatingAuthenticationEntryPoint.Builder entryPointByRequestMatcher = this.entryPointByRequestMatcherByAuthority
121+
.computeIfAbsent(authority, (k) -> DelegatingAuthenticationEntryPoint.builder());
122+
entryPointByRequestMatcher.addEntryPointFor(entryPoint, requestMatcher);
131123
}
132124
return this;
133125
}
@@ -139,17 +131,9 @@ public EntryPoint authorities(String... authorities) {
139131
public DelegatingMissingAuthorityAccessDeniedHandler build() {
140132
Map<String, AuthenticationEntryPoint> entryPointByAuthority = new LinkedHashMap<>();
141133
for (String authority : this.entryPointByRequestMatcherByAuthority.keySet()) {
142-
List<RequestMatcherEntry<AuthenticationEntryPoint>> entryPointByRequestMatcher = this.entryPointByRequestMatcherByAuthority
134+
DelegatingAuthenticationEntryPoint.Builder entryPointByRequestMatcher = this.entryPointByRequestMatcherByAuthority
143135
.get(authority);
144-
AuthenticationEntryPoint defaultEntryPoint = entryPointByRequestMatcher.iterator().next().getEntry();
145-
if (entryPointByRequestMatcher.size() == 1) {
146-
entryPointByAuthority.put(authority, defaultEntryPoint);
147-
}
148-
else {
149-
DelegatingAuthenticationEntryPoint entryPoint = new DelegatingAuthenticationEntryPoint(
150-
defaultEntryPoint, entryPointByRequestMatcher);
151-
entryPointByAuthority.put(authority, entryPoint);
152-
}
136+
entryPointByAuthority.put(authority, entryPointByRequestMatcher.build());
153137
}
154138
return new DelegatingMissingAuthorityAccessDeniedHandler(entryPointByAuthority);
155139
}

web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.springframework.security.web.authentication;
1818

1919
import java.io.IOException;
20+
import java.util.Collection;
2021

2122
import jakarta.servlet.RequestDispatcher;
2223
import jakarta.servlet.ServletException;
@@ -30,6 +31,7 @@
3031
import org.springframework.beans.factory.InitializingBean;
3132
import org.springframework.core.log.LogMessage;
3233
import org.springframework.security.core.AuthenticationException;
34+
import org.springframework.security.core.GrantedAuthority;
3335
import org.springframework.security.web.AuthenticationEntryPoint;
3436
import org.springframework.security.web.DefaultRedirectStrategy;
3537
import org.springframework.security.web.PortMapper;
@@ -40,6 +42,7 @@
4042
import org.springframework.security.web.util.UrlUtils;
4143
import org.springframework.util.Assert;
4244
import org.springframework.util.StringUtils;
45+
import org.springframework.web.util.UriComponentsBuilder;
4346

4447
/**
4548
* Used by the {@link ExceptionTranslationFilter} to commence a form login authentication
@@ -109,6 +112,12 @@ public void afterPropertiesSet() {
109112
*/
110113
protected String determineUrlToUseForThisRequest(HttpServletRequest request, HttpServletResponse response,
111114
AuthenticationException exception) {
115+
Object value = request.getAttribute(GrantedAuthority.MISSING_AUTHORITIES_ATTRIBUTE);
116+
if (value instanceof Collection<?> authorities) {
117+
return UriComponentsBuilder.fromUriString(getLoginFormUrl())
118+
.queryParam("authority", authorities)
119+
.toUriString();
120+
}
112121
return getLoginFormUrl();
113122
}
114123

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

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@
1818

1919
import java.io.IOException;
2020
import java.nio.charset.StandardCharsets;
21+
import java.util.Collection;
2122
import java.util.Collections;
23+
import java.util.List;
2224
import java.util.Map;
2325
import java.util.function.Function;
26+
import java.util.function.Predicate;
2427
import java.util.stream.Collectors;
2528

2629
import jakarta.servlet.FilterChain;
@@ -31,10 +34,14 @@
3134
import jakarta.servlet.http.HttpServletResponse;
3235
import org.jspecify.annotations.Nullable;
3336

37+
import org.springframework.security.core.Authentication;
38+
import org.springframework.security.core.context.SecurityContextHolder;
39+
import org.springframework.security.core.context.SecurityContextHolderStrategy;
3440
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
3541
import org.springframework.security.web.authentication.rememberme.AbstractRememberMeServices;
3642
import org.springframework.util.Assert;
3743
import org.springframework.web.filter.GenericFilterBean;
44+
import org.springframework.web.util.UriComponentsBuilder;
3845

3946
/**
4047
* For internal use with namespace configuration in the case where a user doesn't
@@ -78,6 +85,8 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
7885

7986
private @Nullable String rememberMeParameter;
8087

88+
private final Collection<String> allowedParameters = List.of("authority");
89+
8190
@SuppressWarnings("NullAway.Init")
8291
private Map<String, String> oauth2AuthenticationUrlToClientName;
8392

@@ -223,16 +232,43 @@ private String generateLoginPageHtml(HttpServletRequest request, boolean loginEr
223232
String errorMsg = "Invalid credentials";
224233
String contextPath = request.getContextPath();
225234

226-
return HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE)
235+
HtmlTemplates.Builder builder = HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE)
227236
.withRawHtml("contextPath", contextPath)
228-
.withRawHtml("javaScript", renderJavaScript(request, contextPath))
229-
.withRawHtml("formLogin", renderFormLogin(request, loginError, logoutSuccess, contextPath, errorMsg))
230-
.withRawHtml("oneTimeTokenLogin",
231-
renderOneTimeTokenLogin(request, loginError, logoutSuccess, contextPath, errorMsg))
232-
.withRawHtml("oauth2Login", renderOAuth2Login(loginError, logoutSuccess, errorMsg, contextPath))
233-
.withRawHtml("saml2Login", renderSaml2Login(loginError, logoutSuccess, errorMsg, contextPath))
234-
.withRawHtml("passkeyLogin", renderPasskeyLogin())
235-
.render();
237+
.withRawHtml("javaScript", "")
238+
.withRawHtml("formLogin", "")
239+
.withRawHtml("oneTimeTokenLogin", "")
240+
.withRawHtml("oauth2Login", "")
241+
.withRawHtml("saml2Login", "")
242+
.withRawHtml("passkeyLogin", "");
243+
244+
Predicate<String> wantsAuthority = wantsAuthority(request);
245+
if (wantsAuthority.test("FACTOR_WEBAUTHN")) {
246+
builder.withRawHtml("javaScript", renderJavaScript(request, contextPath))
247+
.withRawHtml("passkeyLogin", renderPasskeyLogin());
248+
}
249+
if (wantsAuthority.test("FACTOR_PASSWORD")) {
250+
builder.withRawHtml("formLogin",
251+
renderFormLogin(request, loginError, logoutSuccess, contextPath, errorMsg));
252+
}
253+
if (wantsAuthority.test("FACTOR_OTT")) {
254+
builder.withRawHtml("oneTimeTokenLogin",
255+
renderOneTimeTokenLogin(request, loginError, logoutSuccess, contextPath, errorMsg));
256+
}
257+
if (wantsAuthority.test("FACTOR_AUTHORIZATION_CODE")) {
258+
builder.withRawHtml("oauth2Login", renderOAuth2Login(loginError, logoutSuccess, errorMsg, contextPath));
259+
}
260+
if (wantsAuthority.test("FACTOR_SAML_RESPONSE")) {
261+
builder.withRawHtml("saml2Login", renderSaml2Login(loginError, logoutSuccess, errorMsg, contextPath));
262+
}
263+
return builder.render();
264+
}
265+
266+
private Predicate<String> wantsAuthority(HttpServletRequest request) {
267+
String[] authorities = request.getParameterValues("authority");
268+
if (authorities == null) {
269+
return (authority) -> true;
270+
}
271+
return List.of(authorities)::contains;
236272
}
237273

238274
private String renderJavaScript(HttpServletRequest request, String contextPath) {
@@ -413,10 +449,19 @@ private boolean matches(HttpServletRequest request, @Nullable String url) {
413449
if (request.getQueryString() != null) {
414450
uri += "?" + request.getQueryString();
415451
}
452+
UriComponentsBuilder addAllowed = UriComponentsBuilder.fromUriString(url);
453+
for (String parameter : this.allowedParameters) {
454+
String[] values = request.getParameterValues(parameter);
455+
if (values != null) {
456+
for (String value : values) {
457+
addAllowed.queryParam(parameter, value);
458+
}
459+
}
460+
}
416461
if ("".equals(request.getContextPath())) {
417-
return uri.equals(url);
462+
return uri.equals(addAllowed.toUriString());
418463
}
419-
return uri.equals(request.getContextPath() + url);
464+
return uri.equals(request.getContextPath() + addAllowed.toUriString());
420465
}
421466

422467
private static final String CSRF_HEADERS = """

web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import org.springframework.security.authentication.BadCredentialsException;
2929
import org.springframework.security.web.WebAttributes;
3030
import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
31+
import org.springframework.security.web.servlet.TestMockHttpServletRequests;
3132

3233
import static org.assertj.core.api.Assertions.assertThat;
3334
import static org.mockito.Mockito.mock;
@@ -191,6 +192,60 @@ public void generateWhenOneTimeTokenLoginThenOttForm() throws Exception {
191192
""");
192193
}
193194

195+
@Test
196+
public void generateWhenOneTimeTokenRequestedThenOttForm() throws Exception {
197+
DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter();
198+
filter.setLoginPageUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL);
199+
filter.setFormLoginEnabled(true);
200+
filter.setOneTimeTokenEnabled(true);
201+
filter.setOneTimeTokenGenerationUrl("/ott/authenticate");
202+
MockHttpServletResponse response = new MockHttpServletResponse();
203+
filter.doFilter(TestMockHttpServletRequests.get("/login?authority=FACTOR_OTT").build(), response, this.chain);
204+
assertThat(response.getContentAsString()).contains("Request a One-Time Token");
205+
assertThat(response.getContentAsString()).contains("""
206+
<form id="ott-form" class="login-form" method="post" action="/ott/authenticate">
207+
<h2>Request a One-Time Token</h2>
208+
209+
<p>
210+
<label for="ott-username" class="screenreader">Username</label>
211+
<input type="text" id="ott-username" name="username" placeholder="Username" required>
212+
</p>
213+
214+
<button class="primary" type="submit" form="ott-form">Send Token</button>
215+
</form>
216+
""");
217+
assertThat(response.getContentAsString()).doesNotContain("Password");
218+
}
219+
220+
@Test
221+
public void generateWhenTwoAuthoritiesRequestedThenBothForms() throws Exception {
222+
DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter();
223+
filter.setLoginPageUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL);
224+
filter.setFormLoginEnabled(true);
225+
filter.setUsernameParameter("username");
226+
filter.setPasswordParameter("password");
227+
filter.setOneTimeTokenEnabled(true);
228+
filter.setOneTimeTokenGenerationUrl("/ott/authenticate");
229+
MockHttpServletResponse response = new MockHttpServletResponse();
230+
filter.doFilter(
231+
TestMockHttpServletRequests.get("/login?authority=FACTOR_OTT&authority=FACTOR_PASSWORD").build(),
232+
response, this.chain);
233+
assertThat(response.getContentAsString()).contains("Request a One-Time Token");
234+
assertThat(response.getContentAsString()).contains("""
235+
<form id="ott-form" class="login-form" method="post" action="/ott/authenticate">
236+
<h2>Request a One-Time Token</h2>
237+
238+
<p>
239+
<label for="ott-username" class="screenreader">Username</label>
240+
<input type="text" id="ott-username" name="username" placeholder="Username" required>
241+
</p>
242+
243+
<button class="primary" type="submit" form="ott-form">Send Token</button>
244+
</form>
245+
""");
246+
assertThat(response.getContentAsString()).contains("Password");
247+
}
248+
194249
@Test
195250
void generatesThenRenders() throws ServletException, IOException {
196251
DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter(

web/src/test/java/org/springframework/security/web/servlet/TestMockHttpServletRequests.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.springframework.security.web.servlet;
1818

1919
import java.util.LinkedHashMap;
20+
import java.util.List;
2021
import java.util.Map;
2122
import java.util.function.Consumer;
2223
import java.util.regex.Matcher;
@@ -27,6 +28,7 @@
2728
import org.springframework.http.HttpMethod;
2829
import org.springframework.mock.web.MockHttpServletRequest;
2930
import org.springframework.util.StringUtils;
31+
import org.springframework.web.util.UriComponentsBuilder;
3032

3133
public final class TestMockHttpServletRequests {
3234

@@ -149,6 +151,14 @@ public Builder serverName(String serverName) {
149151

150152
public MockHttpServletRequest build() {
151153
MockHttpServletRequest request = new MockHttpServletRequest();
154+
Map<String, List<String>> params = UriComponentsBuilder.fromUriString("?" + this.queryString)
155+
.build()
156+
.getQueryParams();
157+
for (Map.Entry<String, List<String>> entry : params.entrySet()) {
158+
for (String value : entry.getValue()) {
159+
request.addParameter(entry.getKey(), value);
160+
}
161+
}
152162
applyElement(request::setContextPath, this.contextPath);
153163
applyElement(request::setContextPath, this.contextPath);
154164
applyElement(request::setMethod, this.method.name());

0 commit comments

Comments
 (0)