parent
9f317757c3
commit
e813aad82b
|
|
@ -402,7 +402,7 @@ public class FormLoginConfigurerTests {
|
|||
UserDetails user = PasswordEncodedUser.user();
|
||||
this.mockMvc.perform(get("/profile").with(user(user)))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("http://localhost/login"));
|
||||
.andExpect(redirectedUrl("http://localhost/login?authority=FACTOR_PASSWORD"));
|
||||
this.mockMvc
|
||||
.perform(post("/ott/generate").param("username", "rod")
|
||||
.with(user(user))
|
||||
|
|
@ -418,11 +418,11 @@ public class FormLoginConfigurerTests {
|
|||
user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_OTT").build();
|
||||
this.mockMvc.perform(get("/profile").with(user(user)))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("http://localhost/login"));
|
||||
.andExpect(redirectedUrl("http://localhost/login?authority=FACTOR_PASSWORD"));
|
||||
user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_PASSWORD").build();
|
||||
this.mockMvc.perform(get("/profile").with(user(user)))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("http://localhost/login"));
|
||||
.andExpect(redirectedUrl("http://localhost/login?authority=FACTOR_OTT"));
|
||||
user = PasswordEncodedUser.withUserDetails(user)
|
||||
.authorities("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT")
|
||||
.build();
|
||||
|
|
@ -438,7 +438,7 @@ public class FormLoginConfigurerTests {
|
|||
this.mockMvc.perform(get("/login")).andExpect(status().isOk());
|
||||
this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.x509("rod.cer")))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("http://localhost/login"));
|
||||
.andExpect(redirectedUrl("http://localhost/login?authority=FACTOR_PASSWORD"));
|
||||
this.mockMvc
|
||||
.perform(post("/login").param("username", "rod")
|
||||
.param("password", "password")
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ import org.springframework.security.authorization.AuthorizationManager;
|
|||
*/
|
||||
public interface GrantedAuthority extends Serializable {
|
||||
|
||||
String MISSING_AUTHORITIES_ATTRIBUTE = GrantedAuthority.class + ".missingAuthorities";
|
||||
|
||||
/**
|
||||
* If the <code>GrantedAuthority</code> can be represented as a <code>String</code>
|
||||
* and that <code>String</code> is sufficient in precision to be relied upon for an
|
||||
|
|
|
|||
|
|
@ -91,14 +91,19 @@ public final class DelegatingMissingAuthorityAccessDeniedHandler implements Acce
|
|||
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException denied)
|
||||
throws IOException, ServletException {
|
||||
Collection<GrantedAuthority> authorities = missingAuthorities(denied);
|
||||
AuthenticationEntryPoint entryPoint = entryPoint(authorities);
|
||||
if (entryPoint == null) {
|
||||
this.defaultAccessDeniedHandler.handle(request, response, denied);
|
||||
for (GrantedAuthority needed : authorities) {
|
||||
AuthenticationEntryPoint entryPoint = this.entryPoints.get(needed.getAuthority());
|
||||
if (entryPoint == null) {
|
||||
continue;
|
||||
}
|
||||
this.requestCache.saveRequest(request, response);
|
||||
request.setAttribute(GrantedAuthority.MISSING_AUTHORITIES_ATTRIBUTE, List.of(needed));
|
||||
String message = String.format("Missing Authorities %s", List.of(needed));
|
||||
AuthenticationException ex = new InsufficientAuthenticationException(message, denied);
|
||||
entryPoint.commence(request, response, ex);
|
||||
return;
|
||||
}
|
||||
this.requestCache.saveRequest(request, response);
|
||||
AuthenticationException ex = new InsufficientAuthenticationException("missing authorities", denied);
|
||||
entryPoint.commence(request, response, ex);
|
||||
this.defaultAccessDeniedHandler.handle(request, response, denied);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -121,17 +126,6 @@ public final class DelegatingMissingAuthorityAccessDeniedHandler implements Acce
|
|||
this.requestCache = requestCache;
|
||||
}
|
||||
|
||||
private @Nullable AuthenticationEntryPoint entryPoint(Collection<GrantedAuthority> authorities) {
|
||||
for (GrantedAuthority needed : authorities) {
|
||||
AuthenticationEntryPoint entryPoint = this.entryPoints.get(needed.getAuthority());
|
||||
if (entryPoint == null) {
|
||||
continue;
|
||||
}
|
||||
return entryPoint;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Collection<GrantedAuthority> missingAuthorities(AccessDeniedException ex) {
|
||||
AuthorizationDeniedException denied = findAuthorizationDeniedException(ex);
|
||||
if (denied == null) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package org.springframework.security.web.authentication;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
|
||||
import jakarta.servlet.RequestDispatcher;
|
||||
import jakarta.servlet.ServletException;
|
||||
|
|
@ -30,6 +31,7 @@ import org.jspecify.annotations.Nullable;
|
|||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.core.log.LogMessage;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.DefaultRedirectStrategy;
|
||||
import org.springframework.security.web.PortMapper;
|
||||
|
|
@ -40,6 +42,7 @@ import org.springframework.security.web.util.RedirectUrlBuilder;
|
|||
import org.springframework.security.web.util.UrlUtils;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* Used by the {@link ExceptionTranslationFilter} to commence a form login authentication
|
||||
|
|
@ -109,6 +112,12 @@ public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoin
|
|||
*/
|
||||
protected String determineUrlToUseForThisRequest(HttpServletRequest request, HttpServletResponse response,
|
||||
AuthenticationException exception) {
|
||||
Object value = request.getAttribute(GrantedAuthority.MISSING_AUTHORITIES_ATTRIBUTE);
|
||||
if (value instanceof Collection<?> authorities) {
|
||||
return UriComponentsBuilder.fromUriString(getLoginFormUrl())
|
||||
.queryParam("authority", authorities)
|
||||
.toUriString();
|
||||
}
|
||||
return getLoginFormUrl();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,9 +18,12 @@ package org.springframework.security.web.authentication.ui;
|
|||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
|
|
@ -31,10 +34,14 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.context.SecurityContextHolderStrategy;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.authentication.rememberme.AbstractRememberMeServices;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.filter.GenericFilterBean;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* For internal use with namespace configuration in the case where a user doesn't
|
||||
|
|
@ -78,6 +85,8 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
|
|||
|
||||
private @Nullable String rememberMeParameter;
|
||||
|
||||
private final Collection<String> allowedParameters = List.of("authority");
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
private Map<String, String> oauth2AuthenticationUrlToClientName;
|
||||
|
||||
|
|
@ -223,16 +232,43 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
|
|||
String errorMsg = "Invalid credentials";
|
||||
String contextPath = request.getContextPath();
|
||||
|
||||
return HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE)
|
||||
HtmlTemplates.Builder builder = HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE)
|
||||
.withRawHtml("contextPath", contextPath)
|
||||
.withRawHtml("javaScript", renderJavaScript(request, contextPath))
|
||||
.withRawHtml("formLogin", renderFormLogin(request, loginError, logoutSuccess, contextPath, errorMsg))
|
||||
.withRawHtml("oneTimeTokenLogin",
|
||||
renderOneTimeTokenLogin(request, loginError, logoutSuccess, contextPath, errorMsg))
|
||||
.withRawHtml("oauth2Login", renderOAuth2Login(loginError, logoutSuccess, errorMsg, contextPath))
|
||||
.withRawHtml("saml2Login", renderSaml2Login(loginError, logoutSuccess, errorMsg, contextPath))
|
||||
.withRawHtml("passkeyLogin", renderPasskeyLogin())
|
||||
.render();
|
||||
.withRawHtml("javaScript", "")
|
||||
.withRawHtml("formLogin", "")
|
||||
.withRawHtml("oneTimeTokenLogin", "")
|
||||
.withRawHtml("oauth2Login", "")
|
||||
.withRawHtml("saml2Login", "")
|
||||
.withRawHtml("passkeyLogin", "");
|
||||
|
||||
Predicate<String> wantsAuthority = wantsAuthority(request);
|
||||
if (wantsAuthority.test("FACTOR_WEBAUTHN")) {
|
||||
builder.withRawHtml("javaScript", renderJavaScript(request, contextPath))
|
||||
.withRawHtml("passkeyLogin", renderPasskeyLogin());
|
||||
}
|
||||
if (wantsAuthority.test("FACTOR_PASSWORD")) {
|
||||
builder.withRawHtml("formLogin",
|
||||
renderFormLogin(request, loginError, logoutSuccess, contextPath, errorMsg));
|
||||
}
|
||||
if (wantsAuthority.test("FACTOR_OTT")) {
|
||||
builder.withRawHtml("oneTimeTokenLogin",
|
||||
renderOneTimeTokenLogin(request, loginError, logoutSuccess, contextPath, errorMsg));
|
||||
}
|
||||
if (wantsAuthority.test("FACTOR_AUTHORIZATION_CODE")) {
|
||||
builder.withRawHtml("oauth2Login", renderOAuth2Login(loginError, logoutSuccess, errorMsg, contextPath));
|
||||
}
|
||||
if (wantsAuthority.test("FACTOR_SAML_RESPONSE")) {
|
||||
builder.withRawHtml("saml2Login", renderSaml2Login(loginError, logoutSuccess, errorMsg, contextPath));
|
||||
}
|
||||
return builder.render();
|
||||
}
|
||||
|
||||
private Predicate<String> wantsAuthority(HttpServletRequest request) {
|
||||
String[] authorities = request.getParameterValues("authority");
|
||||
if (authorities == null) {
|
||||
return (authority) -> true;
|
||||
}
|
||||
return List.of(authorities)::contains;
|
||||
}
|
||||
|
||||
private String renderJavaScript(HttpServletRequest request, String contextPath) {
|
||||
|
|
@ -413,10 +449,19 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
|
|||
if (request.getQueryString() != null) {
|
||||
uri += "?" + request.getQueryString();
|
||||
}
|
||||
if ("".equals(request.getContextPath())) {
|
||||
return uri.equals(url);
|
||||
UriComponentsBuilder addAllowed = UriComponentsBuilder.fromUriString(url);
|
||||
for (String parameter : this.allowedParameters) {
|
||||
String[] values = request.getParameterValues(parameter);
|
||||
if (values != null) {
|
||||
for (String value : values) {
|
||||
addAllowed.queryParam(parameter, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return uri.equals(request.getContextPath() + url);
|
||||
if ("".equals(request.getContextPath())) {
|
||||
return uri.equals(addAllowed.toUriString());
|
||||
}
|
||||
return uri.equals(request.getContextPath() + addAllowed.toUriString());
|
||||
}
|
||||
|
||||
private static final String CSRF_HEADERS = """
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import org.springframework.mock.web.MockHttpServletResponse;
|
|||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.web.WebAttributes;
|
||||
import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
|
||||
import org.springframework.security.web.servlet.TestMockHttpServletRequests;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
|
@ -191,6 +192,60 @@ public class DefaultLoginPageGeneratingFilterTests {
|
|||
""");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generateWhenOneTimeTokenRequestedThenOttForm() throws Exception {
|
||||
DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter();
|
||||
filter.setLoginPageUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL);
|
||||
filter.setFormLoginEnabled(true);
|
||||
filter.setOneTimeTokenEnabled(true);
|
||||
filter.setOneTimeTokenGenerationUrl("/ott/authenticate");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilter(TestMockHttpServletRequests.get("/login?authority=FACTOR_OTT").build(), response, this.chain);
|
||||
assertThat(response.getContentAsString()).contains("Request a One-Time Token");
|
||||
assertThat(response.getContentAsString()).contains("""
|
||||
<form id="ott-form" class="login-form" method="post" action="/ott/authenticate">
|
||||
<h2>Request a One-Time Token</h2>
|
||||
|
||||
<p>
|
||||
<label for="ott-username" class="screenreader">Username</label>
|
||||
<input type="text" id="ott-username" name="username" placeholder="Username" required>
|
||||
</p>
|
||||
|
||||
<button class="primary" type="submit" form="ott-form">Send Token</button>
|
||||
</form>
|
||||
""");
|
||||
assertThat(response.getContentAsString()).doesNotContain("Password");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generateWhenTwoAuthoritiesRequestedThenBothForms() throws Exception {
|
||||
DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter();
|
||||
filter.setLoginPageUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL);
|
||||
filter.setFormLoginEnabled(true);
|
||||
filter.setUsernameParameter("username");
|
||||
filter.setPasswordParameter("password");
|
||||
filter.setOneTimeTokenEnabled(true);
|
||||
filter.setOneTimeTokenGenerationUrl("/ott/authenticate");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilter(
|
||||
TestMockHttpServletRequests.get("/login?authority=FACTOR_OTT&authority=FACTOR_PASSWORD").build(),
|
||||
response, this.chain);
|
||||
assertThat(response.getContentAsString()).contains("Request a One-Time Token");
|
||||
assertThat(response.getContentAsString()).contains("""
|
||||
<form id="ott-form" class="login-form" method="post" action="/ott/authenticate">
|
||||
<h2>Request a One-Time Token</h2>
|
||||
|
||||
<p>
|
||||
<label for="ott-username" class="screenreader">Username</label>
|
||||
<input type="text" id="ott-username" name="username" placeholder="Username" required>
|
||||
</p>
|
||||
|
||||
<button class="primary" type="submit" form="ott-form">Send Token</button>
|
||||
</form>
|
||||
""");
|
||||
assertThat(response.getContentAsString()).contains("Password");
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatesThenRenders() throws ServletException, IOException {
|
||||
DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter(
|
||||
|
|
|
|||
Loading…
Reference in New Issue