Merge branch 'mfa'

Closes gh-2603
This commit is contained in:
Josh Cummings 2025-09-23 18:23:11 -06:00
commit 28aad8855c
No known key found for this signature in database
GPG Key ID: 869B37A20E876129
47 changed files with 3106 additions and 22 deletions

View File

@ -68,6 +68,7 @@ public final class DefaultLoginPageConfigurer<H extends HttpSecurityBuilder<H>>
@Override
public void init(H http) {
this.loginPageGeneratingFilter.setSecurityContextHolderStrategy(getSecurityContextHolderStrategy());
this.loginPageGeneratingFilter.setResolveHiddenInputs(DefaultLoginPageConfigurer.this::hiddenInputs);
this.logoutPageGeneratingFilter.setResolveHiddenInputs(DefaultLoginPageConfigurer.this::hiddenInputs);
http.setSharedObject(DefaultLoginPageGeneratingFilter.class, this.loginPageGeneratingFilter);

View File

@ -17,15 +17,18 @@
package org.springframework.security.config.annotation.web.configurers;
import java.util.LinkedHashMap;
import java.util.function.Consumer;
import org.jspecify.annotations.Nullable;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
import org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.security.web.access.RequestMatcherDelegatingAccessDeniedHandler;
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
@ -77,6 +80,8 @@ public final class ExceptionHandlingConfigurer<H extends HttpSecurityBuilder<H>>
private LinkedHashMap<RequestMatcher, AccessDeniedHandler> defaultDeniedHandlerMappings = new LinkedHashMap<>();
private DelegatingMissingAuthorityAccessDeniedHandler.@Nullable Builder missingAuthoritiesHandlerBuilder;
/**
* Creates a new instance
* @see HttpSecurity#exceptionHandling(Customizer)
@ -127,6 +132,43 @@ public final class ExceptionHandlingConfigurer<H extends HttpSecurityBuilder<H>>
return this;
}
/**
* Sets a default {@link AuthenticationEntryPoint} to be used which prefers being
* invoked for the provided missing {@link GrantedAuthority}.
* @param entryPoint the {@link AuthenticationEntryPoint} to use for the given
* {@code authority}
* @param authority the authority
* @return the {@link ExceptionHandlingConfigurer} for further customizations
* @since 7.0
*/
public ExceptionHandlingConfigurer<H> defaultDeniedHandlerForMissingAuthority(AuthenticationEntryPoint entryPoint,
String authority) {
if (this.missingAuthoritiesHandlerBuilder == null) {
this.missingAuthoritiesHandlerBuilder = DelegatingMissingAuthorityAccessDeniedHandler.builder();
}
this.missingAuthoritiesHandlerBuilder.addEntryPointFor(entryPoint, authority);
return this;
}
/**
* Sets a default {@link AuthenticationEntryPoint} to be used which prefers being
* invoked for the provided missing {@link GrantedAuthority}.
* @param entryPoint a consumer of a
* {@link DelegatingAuthenticationEntryPoint.Builder} to use for the given
* {@code authority}
* @param authority the authority
* @return the {@link ExceptionHandlingConfigurer} for further customizations
* @since 7.0
*/
public ExceptionHandlingConfigurer<H> defaultDeniedHandlerForMissingAuthority(
Consumer<DelegatingAuthenticationEntryPoint.Builder> entryPoint, String authority) {
if (this.missingAuthoritiesHandlerBuilder == null) {
this.missingAuthoritiesHandlerBuilder = DelegatingMissingAuthorityAccessDeniedHandler.builder();
}
this.missingAuthoritiesHandlerBuilder.addEntryPointFor(entryPoint, authority);
return this;
}
/**
* Sets the {@link AuthenticationEntryPoint} to be used.
*
@ -229,6 +271,17 @@ public final class ExceptionHandlingConfigurer<H extends HttpSecurityBuilder<H>>
}
private AccessDeniedHandler createDefaultDeniedHandler(H http) {
AccessDeniedHandler defaults = createDefaultAccessDeniedHandler(http);
if (this.missingAuthoritiesHandlerBuilder == null) {
return defaults;
}
DelegatingMissingAuthorityAccessDeniedHandler deniedHandler = this.missingAuthoritiesHandlerBuilder.build();
deniedHandler.setRequestCache(getRequestCache(http));
deniedHandler.setDefaultAccessDeniedHandler(defaults);
return deniedHandler;
}
private AccessDeniedHandler createDefaultAccessDeniedHandler(H http) {
if (this.defaultDeniedHandlerMappings.isEmpty()) {
return new AccessDeniedHandlerImpl();
}

View File

@ -231,6 +231,13 @@ public final class FormLoginConfigurer<H extends HttpSecurityBuilder<H>> extends
public void init(H http) throws Exception {
super.init(http);
initDefaultLoginFilter(http);
ExceptionHandlingConfigurer<H> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
if (exceptions != null) {
AuthenticationEntryPoint entryPoint = getAuthenticationEntryPoint();
RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http);
exceptions.defaultDeniedHandlerForMissingAuthority((ep) -> ep.addEntryPointFor(entryPoint, requestMatcher),
"FACTOR_PASSWORD");
}
}
@Override

View File

@ -192,8 +192,10 @@ public final class HttpBasicConfigurer<B extends HttpSecurityBuilder<B>>
if (exceptionHandling == null) {
return;
}
exceptionHandling.defaultAuthenticationEntryPointFor(postProcess(this.authenticationEntryPoint),
preferredMatcher);
AuthenticationEntryPoint entryPoint = postProcess(this.authenticationEntryPoint);
exceptionHandling.defaultAuthenticationEntryPointFor(entryPoint, preferredMatcher);
exceptionHandling.defaultDeniedHandlerForMissingAuthority(
(ep) -> ep.addEntryPointFor(entryPoint, preferredMatcher), "FACTOR_PASSWORD");
}
private void registerDefaultLogoutSuccessHandler(B http, RequestMatcher preferredMatcher) {

View File

@ -27,11 +27,14 @@ import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
import org.springframework.security.web.authentication.ui.DefaultResourcesFilter;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.util.matcher.AnyRequestMatcher;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsFilter;
import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter;
@ -150,6 +153,16 @@ public class WebAuthnConfigurer<H extends HttpSecurityBuilder<H>>
return this;
}
@Override
public void init(H http) throws Exception {
ExceptionHandlingConfigurer<H> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
if (exceptions != null) {
AuthenticationEntryPoint entryPoint = new LoginUrlAuthenticationEntryPoint("/login");
exceptions.defaultDeniedHandlerForMissingAuthority(
(ep) -> ep.addEntryPointFor(entryPoint, AnyRequestMatcher.INSTANCE), "FACTOR_WEBAUTHN");
}
}
@Override
public void configure(H http) throws Exception {
UserDetailsService userDetailsService = getSharedOrBean(http, UserDetailsService.class)

View File

@ -184,7 +184,9 @@ public final class X509Configurer<H extends HttpSecurityBuilder<H>>
.setSharedObject(AuthenticationEntryPoint.class, new Http403ForbiddenEntryPoint());
ExceptionHandlingConfigurer<H> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
if (exceptions != null) {
exceptions.defaultAuthenticationEntryPointFor(new Http403ForbiddenEntryPoint(), AnyRequestMatcher.INSTANCE);
AuthenticationEntryPoint forbidden = new Http403ForbiddenEntryPoint();
exceptions.defaultDeniedHandlerForMissingAuthority(
(ep) -> ep.addEntryPointFor(forbidden, AnyRequestMatcher.INSTANCE), "FACTOR_X509");
}
}

View File

@ -40,6 +40,7 @@ import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer;
import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer;
import org.springframework.security.context.DelegatingApplicationListener;
import org.springframework.security.core.Authentication;
@ -556,11 +557,18 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
RequestMatcher loginUrlMatcher = new AndRequestMatcher(notXRequestedWith,
new NegatedRequestMatcher(defaultLoginPageMatcher), formLoginNotEnabled);
// @formatter:off
return DelegatingAuthenticationEntryPoint.builder()
AuthenticationEntryPoint loginEntryPoint = DelegatingAuthenticationEntryPoint.builder()
.addEntryPointFor(loginUrlEntryPoint, loginUrlMatcher)
.defaultEntryPoint(getAuthenticationEntryPoint())
.build();
// @formatter:on
ExceptionHandlingConfigurer<B> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
if (exceptions != null) {
RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http);
exceptions.defaultDeniedHandlerForMissingAuthority(
(ep) -> ep.addEntryPointFor(loginEntryPoint, requestMatcher), "FACTOR_AUTHORIZATION_CODE");
}
return loginEntryPoint;
}
private RequestMatcher getFormLoginNotEnabledRequestMatcher(B http) {

View File

@ -327,6 +327,8 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
RequestMatcher preferredMatcher = new OrRequestMatcher(
Arrays.asList(this.requestMatcher, X_REQUESTED_WITH, restNotHtmlMatcher, allMatcher));
exceptionHandling.defaultAuthenticationEntryPointFor(this.authenticationEntryPoint, preferredMatcher);
exceptionHandling.defaultDeniedHandlerForMissingAuthority(
(ep) -> ep.addEntryPointFor(this.authenticationEntryPoint, preferredMatcher), "FACTOR_BEARER");
}
}

View File

@ -35,8 +35,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.AuthenticationConverter;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
@ -134,6 +136,13 @@ public final class OneTimeTokenLoginConfigurer<H extends HttpSecurityBuilder<H>>
AuthenticationProvider authenticationProvider = getAuthenticationProvider();
http.authenticationProvider(postProcess(authenticationProvider));
intiDefaultLoginFilter(http);
ExceptionHandlingConfigurer<H> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
if (exceptions != null) {
AuthenticationEntryPoint entryPoint = getAuthenticationEntryPoint();
RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http);
exceptions.defaultDeniedHandlerForMissingAuthority((ep) -> ep.addEntryPointFor(entryPoint, requestMatcher),
"FACTOR_OTT");
}
}
private void intiDefaultLoginFilter(H http) {

View File

@ -33,6 +33,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer;
import org.springframework.security.core.Authentication;
import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider;
@ -343,11 +344,18 @@ public final class Saml2LoginConfigurer<B extends HttpSecurityBuilder<B>>
RequestMatcher loginUrlMatcher = new AndRequestMatcher(notXRequestedWith,
new NegatedRequestMatcher(defaultLoginPageMatcher));
// @formatter:off
return DelegatingAuthenticationEntryPoint.builder()
AuthenticationEntryPoint loginEntryPoint = DelegatingAuthenticationEntryPoint.builder()
.addEntryPointFor(loginUrlEntryPoint, loginUrlMatcher)
.defaultEntryPoint(getAuthenticationEntryPoint())
.build();
// @formatter:on
ExceptionHandlingConfigurer<B> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
if (exceptions != null) {
RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http);
exceptions.defaultDeniedHandlerForMissingAuthority(
(ep) -> ep.addEntryPointFor(loginEntryPoint, requestMatcher), "FACTOR_SAML_RESPONSE");
}
return loginEntryPoint;
}
private void setAuthenticationRequestRepository(B http,

View File

@ -22,9 +22,18 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authorization.AllAuthoritiesAuthorizationManager;
import org.springframework.security.authorization.AuthenticatedAuthorizationManager;
import org.springframework.security.authorization.AuthorityAuthorizationManager;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.authorization.AuthorizationManagers;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.ObjectPostProcessor;
import org.springframework.security.config.annotation.SecurityContextChangedListenerConfig;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
@ -34,17 +43,25 @@ import org.springframework.security.config.users.AuthenticationTestConfiguration
import org.springframework.security.core.context.SecurityContextChangedListener;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.core.userdetails.PasswordEncodedUser;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
import org.springframework.security.web.PortMapper;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import static org.mockito.ArgumentMatchers.any;
@ -57,6 +74,7 @@ import static org.springframework.security.config.Customizer.withDefaults;
import static org.springframework.security.config.annotation.SecurityContextChangedListenerArgumentMatchers.setAuthentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.logout;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@ -378,6 +396,62 @@ public class FormLoginConfigurerTests {
verify(ObjectPostProcessorConfig.objectPostProcessor).postProcess(any(ExceptionTranslationFilter.class));
}
@Test
void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception {
this.spring.register(MfaDslConfig.class, UserConfig.class).autowire();
UserDetails user = PasswordEncodedUser.user();
this.mockMvc.perform(get("/profile").with(user(user)))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("http://localhost/login?factor=password"));
this.mockMvc
.perform(post("/ott/generate").param("username", "rod")
.with(user(user))
.with(SecurityMockMvcRequestPostProcessors.csrf()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/ott/sent"));
this.mockMvc
.perform(post("/login").param("username", "rod")
.param("password", "password")
.with(SecurityMockMvcRequestPostProcessors.csrf()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/"));
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?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?factor=ott"));
user = PasswordEncodedUser.withUserDetails(user)
.authorities("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT")
.build();
this.mockMvc.perform(get("/profile").with(user(user))).andExpect(status().isNotFound());
}
@Test
void requestWhenUnauthenticatedX509ThenRequiresTwoSteps() throws Exception {
this.spring.register(MfaDslX509Config.class, UserConfig.class, BasicMfaController.class).autowire();
this.mockMvc.perform(get("/profile")).andExpect(status().is3xxRedirection());
this.mockMvc.perform(get("/profile").with(user(User.withUsername("rod").authorities("profile:read").build())))
.andExpect(status().isForbidden());
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?factor=password"));
this.mockMvc
.perform(post("/login").param("username", "rod")
.param("password", "password")
.with(SecurityMockMvcRequestPostProcessors.x509("rod.cer"))
.with(SecurityMockMvcRequestPostProcessors.csrf()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/"));
UserDetails authorized = PasswordEncodedUser.withUsername("rod")
.authorities("profile:read", "FACTOR_X509", "FACTOR_PASSWORD")
.build();
this.mockMvc.perform(get("/profile").with(user(authorized))).andExpect(status().isOk());
}
@Configuration
@EnableWebSecurity
static class RequestCacheConfig {
@ -714,4 +788,107 @@ public class FormLoginConfigurerTests {
}
@Configuration
@EnableWebSecurity
static class MfaDslConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http,
AuthorizationManagerFactory<RequestAuthorizationContext> authz) throws Exception {
// @formatter:off
http
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults())
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/profile").access(authz.hasAuthority("profile:read"))
.anyRequest().access(authz.authenticated())
);
return http.build();
// @formatter:on
}
@Bean
OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
}
@Bean
AuthorizationManagerFactory<?> authz() {
return new AuthorizationManagerFactory<>("FACTOR_PASSWORD", "FACTOR_OTT");
}
}
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
static class MfaDslX509Config {
@Bean
SecurityFilterChain filterChain(HttpSecurity http,
AuthorizationManagerFactory<RequestAuthorizationContext> authz) throws Exception {
// @formatter:off
http
.x509(Customizer.withDefaults())
.formLogin(Customizer.withDefaults())
.authorizeHttpRequests((authorize) -> authorize
.anyRequest().access(authz.authenticated())
);
return http.build();
// @formatter:on
}
@Bean
AuthorizationManagerFactory<?> authz() {
return new AuthorizationManagerFactory<>("FACTOR_X509", "FACTOR_PASSWORD");
}
}
@Configuration
static class UserConfig {
@Bean
UserDetails rod() {
return PasswordEncodedUser.withUsername("rod").password("password").build();
}
@Bean
UserDetailsService users(UserDetails user) {
return new InMemoryUserDetailsManager(user);
}
}
@RestController
static class BasicMfaController {
@GetMapping("/profile")
@PreAuthorize("@authz.hasAuthority('profile:read')")
String profile() {
return "profile";
}
}
public static class AuthorizationManagerFactory<T> {
private final AuthorizationManager<T> authorities;
AuthorizationManagerFactory(String... authorities) {
this.authorities = AllAuthoritiesAuthorizationManager.hasAllAuthorities(authorities);
}
public AuthorizationManager<T> authenticated() {
AuthenticatedAuthorizationManager<T> authenticated = AuthenticatedAuthorizationManager.authenticated();
return AuthorizationManagers.allOf(new AuthorizationDecision(false), this.authorities, authenticated);
}
public AuthorizationManager<T> hasAuthority(String authority) {
AuthorityAuthorizationManager<T> authorized = AuthorityAuthorizationManager.hasAuthority(authority);
return AuthorizationManagers.allOf(new AuthorizationDecision(false), this.authorities, authorized);
}
}
}

View File

@ -49,6 +49,7 @@
***** xref:servlet/authentication/passwords/password-encoder.adoc[PasswordEncoder]
***** xref:servlet/authentication/passwords/dao-authentication-provider.adoc[DaoAuthenticationProvider]
***** xref:servlet/authentication/passwords/ldap.adoc[LDAP]
*** xref:servlet/authentication/adaptive.adoc[Multifactor Authentication]
*** xref:servlet/authentication/persistence.adoc[Persistence]
*** xref:servlet/authentication/passkeys.adoc[Passkeys]
*** xref:servlet/authentication/onetimetoken.adoc[One-Time Token]

View File

@ -0,0 +1,101 @@
= Adaptive Authentication
Since authentication needs can vary from person-to-person and even from one login attempt to the next, Spring Security supports adapting authentication requirements to each situation.
Some of the most common applications of this principal are:
1. *Re-authentication* - Users need to provide authentication again in order to enter an area of elevated security
2. *Multi-factor Authentication* - Users need more than one authentication mechanism to pass in order to access secured resources
3. *Authorizing More Scopes* - Users are allowed to consent to a subset of scopes from an OAuth 2.0 Authorization Server.
Then, if later on a scope that they did not grant is needed, consent can be re-requested for just that scope.
4. *Opting-in to Stronger Authentication Mechanisms* - Users may not be ready yet to start using MFA, but the application wants to allow the subset of security-minded users to opt-in.
5. *Requiring Additional Steps for Suspicious Logins* - The application may notice that the user's IP address has changed, that they are behind a VPN, or some other consideration that requires additional verification
[[re-authentication]]
== Re-authentication
The most common of these is re-authentication.
Imagine an application configured in the following way:
include-code::./SimpleConfiguration[tag=httpSecurity,indent=0]
By default, this application has two authentication mechanisms that it allows, meaning that the user could use either one and be fully-authenticated.
If there is a set of endpoints that require a specific factor, we can specify that in `authorizeHttpRequests` as follows:
include-code::./RequireOttConfiguration[tag=httpSecurity,indent=0]
<1> - States that all `/profile/**` endpoints require one-time-token login to be authorized
Given the above configuration, users can log in with any mechanism that you support.
And, if they want to visit the profile page, then Spring Security will redirect them to the One-Time-Token Login page to obtain it.
In this way, the authority given to a user is directly proportional to the amount of proof given.
This adaptive approach allows users to give only the proof needed to perform their intended operations.
[[multi-factor-authentication]]
== Multi-Factor Authentication
You may require that all users require both One-Time-Token login and Username/Password login to access any part of your site.
To require both, you can state an authorization rule with `anyRequest` like so:
include-code::./ListAuthoritiesConfiguration[tag=httpSecurity,indent=0]
<1> - This states that both `FACTOR_PASSWORD` and `FACTOR_OTT` are needed to use any part of the application
Spring Security behind the scenes knows which endpoint to go to depending on which authority is missing.
If the user logged in initially with their username and password, then Spring Security redirects to the One-Time-Token Login page.
If the user logged in initially with a token, then Spring Security redirects to the Username/Password Login page.
[[authorization-manager-factory]]
=== Requiring MFA For All Endpoints
Specifying all authorities for each request pattern could be unwanted boilerplate:
include-code::./ListAuthoritiesEverywhereConfiguration[tag=httpSecurity,indent=0]
<1> - Since all authorities need to be specified for each endpoint, deploying MFA in this way can create unwanted boilerplate
This can be remedied by publishing an `AuthorizationManagerFactory` bean like so:
include-code::./UseAuthorizationManagerFactoryConfiguration[tag=authorizationManagerFactoryBean,indent=0]
This yields a more familiar configuration:
include-code::./UseAuthorizationManagerFactoryConfiguration[tag=httpSecurity,indent=0]
[[obtaining-more-authorization]]
== Authorizing More Scopes
You can also configure exception handling to direct Spring Security on how to obtain a missing scope.
Consider an application that requires a specific OAuth 2.0 scope for a given endpoint:
include-code::./ScopeConfiguration[tag=httpSecurity,indent=0]
If this is also configured with an `AuthorizationManagerFactory` bean like this one:
include-code::./MissingAuthorityConfiguration[tag=authorizationManagerFactoryBean,indent=0]
Then the application will require an X.509 certificate as well as authorization from an OAuth 2.0 authorization server.
In the event that the user does not consent to `profile:read`, this application as it stands will issue a 403.
However, if you have a way for the application to re-ask for consent, then you can implement this in an `AuthenticationEntryPoint` like the following:
include-code::./MissingAuthorityConfiguration[tag=authenticationEntryPoint,indent=0]
Then, your filter chain declaration can bind this entry point to the given authority like so:
include-code::./MissingAuthorityConfiguration[tag=httpSecurity,indent=0]
[[custom-authorization-manager-factory]]
== Programmatically Decide Which Authorities Are Required
`AuthorizationManager` is the core interface for making authorization decisions.
Consider an authorization manager that looks at the logged in user to decide which factors are necessary:
include-code::./CustomAuthorizationManagerFactory[tag=authorizationManager,indent=0]
In this case, using One-Time-Token is only required for those who have opted in.
This can then be enforced by a custom `AuthorizationManagerFactory` implementation:
include-code::./CustomAuthorizationManagerFactory[tag=authorizationManagerFactory,indent=0]

View File

@ -15,6 +15,7 @@ Each section that follows will indicate the more notable removals as well as the
== Core
* Added Support for xref:servlet/authentication/adaptive.adoc[Multi-factor Authentication]
* Removed `AuthorizationManager#check` in favor of `AuthorizationManager#authorize`
* Added javadoc:org.springframework.security.authorization.AllAuthoritiesAuthorizationManager[] and javadoc:org.springframework.security.authorization.AllAuthoritiesReactiveAuthorizationManager[] along with corresponding methods for xref:servlet/authorization/authorize-http-requests.adoc#authorize-requests[Authorizing `HttpServletRequests`] and xref:servlet/authorization/method-security.adoc#using-authorization-expression-fields-and-methods[method security expressions].
* Added xref:servlet/authorization/architecture.adoc#authz-authorization-manager-factory[`AuthorizationManagerFactory`] for creating `AuthorizationManager` instances in xref:servlet/authorization/authorize-http-requests.adoc#customizing-authorization-managers[request-based] and xref:servlet/authorization/method-security.adoc#customizing-authorization-managers[method-based] authorization components

View File

@ -0,0 +1,114 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.docs.servlet.authentication.authorizationmanagerfactory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.config.test.SpringTestContext;
import org.springframework.security.config.test.SpringTestContextExtension;
import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests {@link CustomX509Configuration}.
*
* @author Rob Winch
*/
@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class })
@TestExecutionListeners(WithSecurityContextTestExecutionListener.class)
public class AuthorizationManagerFactoryTests {
public final SpringTestContext spring = new SpringTestContext(this);
@Autowired
MockMvc mockMvc;
@Test
@WithMockUser(authorities = { "FACTOR_PASSWORD", "FACTOR_OTT" })
void getWhenAuthenticatedWithPasswordAndOttThenPermits() throws Exception {
this.spring.register(UseAuthorizationManagerFactoryConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/"))
.andExpect(status().isOk())
.andExpect(authenticated().withUsername("user"));
// @formatter:on
}
@Test
@WithMockUser(authorities = "FACTOR_PASSWORD")
void getWhenAuthenticatedWithPasswordThenRedirectsToOtt() throws Exception {
this.spring.register(UseAuthorizationManagerFactoryConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("http://localhost/login?factor=ott"));
// @formatter:on
}
@Test
@WithMockUser(authorities = "FACTOR_OTT")
void getWhenAuthenticatedWithOttThenRedirectsToPassword() throws Exception {
this.spring.register(UseAuthorizationManagerFactoryConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("http://localhost/login?factor=password"));
// @formatter:on
}
@Test
@WithMockUser
void getWhenAuthenticatedThenRedirectsToPassword() throws Exception {
this.spring.register(UseAuthorizationManagerFactoryConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("http://localhost/login?factor=password"));
// @formatter:on
}
@Test
void getWhenUnauthenticatedThenRedirectsToBoth() throws Exception {
this.spring.register(UseAuthorizationManagerFactoryConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("http://localhost/login"));
// @formatter:on
}
@RestController
static class Http200Controller {
@GetMapping("/**")
String ok() {
return "ok";
}
}
}

View File

@ -0,0 +1,54 @@
package org.springframework.security.docs.servlet.authentication.authorizationmanagerfactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
import static org.springframework.security.authorization.AuthorityAuthorizationManager.hasAuthority;
import static org.springframework.security.authorization.AuthorityAuthorizationManager.hasRole;
import static org.springframework.security.authorization.AuthorizationManagers.allOf;
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class ListAuthoritiesEverywhereConfiguration {
// tag::httpSecurity[]
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/admin/**").access(allOf(hasAuthority("FACTOR_PASSWORD"), hasAuthority("FACTOR_OTT"), hasRole("ADMIN"))) // <1>
.anyRequest().access(allOf(hasAuthority("FACTOR_PASSWORD"), hasAuthority("FACTOR_OTT")))
)
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults());
// @formatter:on
return http.build();
}
// end::httpSecurity[]
@Bean
UserDetailsService userDetailsService() {
return new InMemoryUserDetailsManager(
User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.authorities("app")
.build()
);
}
@Bean
OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
}
}

View File

@ -0,0 +1,60 @@
package org.springframework.security.docs.servlet.authentication.authorizationmanagerfactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authorization.AuthorizationManagerFactory;
import org.springframework.security.authorization.DefaultAuthorizationManagerFactory;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
class UseAuthorizationManagerFactoryConfiguration {
// tag::httpSecurity[]
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults());
// @formatter:on
return http.build();
}
// end::httpSecurity[]
// tag::authorizationManagerFactoryBean[]
@Bean
AuthorizationManagerFactory<Object> authz() {
return DefaultAuthorizationManagerFactory.builder()
.requireAdditionalAuthorities("FACTOR_PASSWORD", "FACTOR_OTT").build();
}
// end::authorizationManagerFactoryBean[]
@Bean
UserDetailsService userDetailsService() {
return new InMemoryUserDetailsManager(
User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.authorities("app")
.build()
);
}
@Bean
OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
}
}

View File

@ -0,0 +1,103 @@
package org.springframework.security.docs.servlet.authentication.customauthorizationmanagerfactory;
import java.util.Collection;
import java.util.function.Supplier;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.expression.SecurityExpressionOperations;
import org.springframework.security.access.expression.SecurityExpressionRoot;
import org.springframework.security.authorization.AuthorityAuthorizationDecision;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.authorization.AuthorizationManagerFactory;
import org.springframework.security.authorization.AuthorizationResult;
import org.springframework.security.authorization.DefaultAuthorizationManagerFactory;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
import org.springframework.stereotype.Component;
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
class CustomAuthorizationManagerFactory {
// tag::httpSecurity[]
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults());
// @formatter:on
return http.build();
}
// end::httpSecurity[]
// tag::authorizationManager[]
@Component
class OptInToMfaAuthorizationManager implements AuthorizationManager<Object> {
@Override
public AuthorizationResult authorize(Supplier<? extends @Nullable Authentication> authentication, Object context) {
MyPrincipal principal = (MyPrincipal) authentication.get().getPrincipal();
if (principal.optedIn()) {
SecurityExpressionOperations sec = new SecurityExpressionRoot<>(authentication, context) {};
return new AuthorityAuthorizationDecision(sec.hasAuthority("FACTOR_OTT"),
AuthorityUtils.createAuthorityList("FACTOR_OTT"));
}
return new AuthorizationDecision(true);
}
}
// end::authorizationManager[]
// tag::authorizationManagerFactory[]
@Bean
AuthorizationManagerFactory<Object> authorizationManagerFactory(OptInToMfaAuthorizationManager optIn) {
DefaultAuthorizationManagerFactory<Object> defaults = new DefaultAuthorizationManagerFactory<>();
defaults.setAdditionalAuthorization(optIn);
return defaults;
}
// end::authorizationManagerFactory[]
@NullMarked
record MyPrincipal(String username, boolean optedIn) implements UserDetails {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return AuthorityUtils.createAuthorityList("app");
}
@Override
public @Nullable String getPassword() {
return null;
}
@Override
public String getUsername() {
return this.username;
}
}
@Bean
UserDetailsService users() {
return (username) -> new MyPrincipal(username, username.equals("optedin"));
}
@Bean
OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
}
}

View File

@ -0,0 +1,97 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.docs.servlet.authentication.customauthorizationmanagerfactory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.config.test.SpringTestContext;
import org.springframework.security.config.test.SpringTestContextExtension;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests {@link CustomX509Configuration}.
*
* @author Rob Winch
*/
@ExtendWith(SpringTestContextExtension.class)
public class CustomAuthorizationManagerFactoryTests {
public final SpringTestContext spring = new SpringTestContext(this);
@Autowired
MockMvc mockMvc;
@Autowired
UserDetailsService users;
@Test
void getWhenOptedInThenRedirectsToOtt() throws Exception {
this.spring.register(CustomAuthorizationManagerFactory.class, Http200Controller.class).autowire();
UserDetails user = this.users.loadUserByUsername("optedin");
// @formatter:off
this.mockMvc.perform(get("/").with(user(user)))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("http://localhost/login?factor=ott"));
// @formatter:on
}
@Test
void getWhenNotOptedInThenAllows() throws Exception {
this.spring.register(CustomAuthorizationManagerFactory.class, Http200Controller.class).autowire();
UserDetails user = this.users.loadUserByUsername("user");
// @formatter:off
this.mockMvc.perform(get("/").with(user(user)))
.andExpect(status().isOk())
.andExpect(authenticated().withUsername("user"));
// @formatter:on
}
@Test
void getWhenOptedAndHasFactorThenAllows() throws Exception {
this.spring.register(CustomAuthorizationManagerFactory.class, Http200Controller.class).autowire();
UserDetails user = this.users.loadUserByUsername("optedin");
TestingAuthenticationToken token = new TestingAuthenticationToken(user, "", "FACTOR_OTT");
// @formatter:off
this.mockMvc.perform(get("/").with(authentication(token)))
.andExpect(status().isOk())
.andExpect(authenticated().withUsername("optedin"));
// @formatter:on
}
@RestController
static class Http200Controller {
@GetMapping("/**")
String ok() {
return "ok";
}
}
}

View File

@ -0,0 +1,52 @@
package org.springframework.security.docs.servlet.authentication.multifactorauthentication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
import static org.springframework.security.authorization.AuthorityAuthorizationManager.hasAuthority;
import static org.springframework.security.authorization.AuthorizationManagers.allOf;
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
class ListAuthoritiesConfiguration {
// tag::httpSecurity[]
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.anyRequest().access(allOf(hasAuthority("FACTOR_PASSWORD"), hasAuthority("FACTOR_OTT"))) // <1>
)
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults());
// @formatter:on
return http.build();
}
// end::httpSecurity[]
@Bean
UserDetailsService userDetailsService() {
return new InMemoryUserDetailsManager(
User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.authorities("app")
.build()
);
}
@Bean
OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
}
}

View File

@ -0,0 +1,114 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.docs.servlet.authentication.multifactorauthentication;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.config.test.SpringTestContext;
import org.springframework.security.config.test.SpringTestContextExtension;
import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests {@link CustomX509Configuration}.
*
* @author Rob Winch
*/
@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class })
@TestExecutionListeners(WithSecurityContextTestExecutionListener.class)
public class MultiFactorAuthenticationTests {
public final SpringTestContext spring = new SpringTestContext(this);
@Autowired
MockMvc mockMvc;
@Test
@WithMockUser(authorities = { "FACTOR_PASSWORD", "FACTOR_OTT" })
void getWhenAuthenticatedWithPasswordAndOttThenPermits() throws Exception {
this.spring.register(ListAuthoritiesConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/"))
.andExpect(status().isOk())
.andExpect(authenticated().withUsername("user"));
// @formatter:on
}
@Test
@WithMockUser(authorities = "FACTOR_PASSWORD")
void getWhenAuthenticatedWithPasswordThenRedirectsToOtt() throws Exception {
this.spring.register(ListAuthoritiesConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("http://localhost/login?factor=ott"));
// @formatter:on
}
@Test
@WithMockUser(authorities = "FACTOR_OTT")
void getWhenAuthenticatedWithOttThenRedirectsToPassword() throws Exception {
this.spring.register(ListAuthoritiesConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("http://localhost/login?factor=password"));
// @formatter:on
}
@Test
@WithMockUser
void getWhenAuthenticatedThenRedirectsToPassword() throws Exception {
this.spring.register(ListAuthoritiesConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("http://localhost/login?factor=password"));
// @formatter:on
}
@Test
void getWhenUnauthenticatedThenRedirectsToBoth() throws Exception {
this.spring.register(ListAuthoritiesConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("http://localhost/login"));
// @formatter:on
}
@RestController
static class Http200Controller {
@GetMapping("/**")
String ok() {
return "ok";
}
}
}

View File

@ -0,0 +1,147 @@
package org.springframework.security.docs.servlet.authentication.obtainingmoreauthorization;
import java.io.IOException;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.authorization.AuthorizationManagerFactory;
import org.springframework.security.authorization.DefaultAuthorizationManagerFactory;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
import org.springframework.stereotype.Component;
import static org.springframework.security.authorization.AllAuthoritiesAuthorizationManager.hasAllAuthorities;
import static org.springframework.security.authorization.AuthorizationManagers.allOf;
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
class MissingAuthorityConfiguration {
// tag::httpSecurity[]
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, ScopeRetrievingAuthenticationEntryPoint oauth2) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/profile/**").hasAuthority("SCOPE_profile:read")
.anyRequest().authenticated()
)
.x509(Customizer.withDefaults())
.oauth2Login(Customizer.withDefaults())
.exceptionHandling((exceptions) -> exceptions
.defaultDeniedHandlerForMissingAuthority(oauth2, "SCOPE_profile:read")
);
// @formatter:on
return http.build();
}
// end::httpSecurity[]
// tag::authorizationManagerFactoryBean[]
@Bean
AuthorizationManagerFactory<RequestAuthorizationContext> authz() {
return new FactorAuthorizationManagerFactory(hasAllAuthorities("FACTOR_X509", "FACTOR_AUTHORIZATION_CODE"));
}
// end::authorizationManagerFactoryBean[]
// tag::authorizationManagerFactory[]
class FactorAuthorizationManagerFactory implements AuthorizationManagerFactory<RequestAuthorizationContext> {
private final AuthorizationManager<RequestAuthorizationContext> hasAuthorities;
private final DefaultAuthorizationManagerFactory<RequestAuthorizationContext> delegate =
new DefaultAuthorizationManagerFactory<>();
FactorAuthorizationManagerFactory(AuthorizationManager<RequestAuthorizationContext> hasAuthorities) {
this.hasAuthorities = hasAuthorities;
}
@Override
public AuthorizationManager<RequestAuthorizationContext> permitAll() {
return this.delegate.permitAll();
}
@Override
public AuthorizationManager<RequestAuthorizationContext> denyAll() {
return this.delegate.denyAll();
}
@Override
public AuthorizationManager<RequestAuthorizationContext> hasRole(String role) {
return hasAnyRole(role);
}
@Override
public AuthorizationManager<RequestAuthorizationContext> hasAnyRole(String... roles) {
return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.hasAnyRole(roles));
}
@Override
public AuthorizationManager<RequestAuthorizationContext> hasAllRoles(String... roles) {
return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.hasAllRoles(roles));
}
@Override
public AuthorizationManager<RequestAuthorizationContext> hasAuthority(String authority) {
return hasAnyAuthority(authority);
}
@Override
public AuthorizationManager<RequestAuthorizationContext> hasAnyAuthority(String... authorities) {
return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.hasAnyAuthority(authorities));
}
@Override
public AuthorizationManager<RequestAuthorizationContext> hasAllAuthorities(String... authorities) {
return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.hasAllAuthorities(authorities));
}
@Override
public AuthorizationManager<RequestAuthorizationContext> authenticated() {
return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.authenticated());
}
@Override
public AuthorizationManager<RequestAuthorizationContext> fullyAuthenticated() {
return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.fullyAuthenticated());
}
@Override
public AuthorizationManager<RequestAuthorizationContext> rememberMe() {
return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.rememberMe());
}
@Override
public AuthorizationManager<RequestAuthorizationContext> anonymous() {
return this.delegate.anonymous();
}
}
// end::authorizationManagerFactory[]
// tag::authenticationEntryPoint[]
@Component
class ScopeRetrievingAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
throws IOException, ServletException {
response.sendRedirect("https://authz.example.org/authorize?scope=profile:read");
}
}
// end::authenticationEntryPoint[]
@Bean
ClientRegistrationRepository clients() {
return new InMemoryClientRegistrationRepository(TestClientRegistrations.clientRegistration().build());
}
}

View File

@ -0,0 +1,102 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.docs.servlet.authentication.obtainingmoreauthorization;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.config.test.SpringTestContext;
import org.springframework.security.config.test.SpringTestContextExtension;
import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests {@link CustomX509Configuration}.
*
* @author Rob Winch
*/
@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class })
@TestExecutionListeners(WithSecurityContextTestExecutionListener.class)
public class ObtainingMoreAuthorizationTests {
public final SpringTestContext spring = new SpringTestContext(this);
@Autowired
MockMvc mockMvc;
@Test
@WithMockUser
void profileWhenScopeConfigurationThenDenies() throws Exception {
this.spring.register(ScopeConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/profile"))
.andExpect(status().isForbidden());
// @formatter:on
}
@Test
@WithMockUser(authorities = { "FACTOR_X509", "FACTOR_AUTHORIZATION_CODE" })
void profileWhenMissingAuthorityConfigurationThenRedirectsToAuthorizationServer() throws Exception {
this.spring.register(MissingAuthorityConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/profile"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("https://authz.example.org/authorize?scope=profile:read"));
// @formatter:on
}
@Test
@WithMockUser(authorities = { "SCOPE_profile:read" })
void profileWhenMissingX509WithOttThenForbidden() throws Exception {
this.spring.register(MissingAuthorityConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/profile"))
.andExpect(status().isForbidden());
// @formatter:on
}
@Test
@WithMockUser(authorities = { "FACTOR_X509", "FACTOR_AUTHORIZATION_CODE", "SCOPE_profile:read" })
void profileWhenAuthenticatedAndHasScopeThenPermits() throws Exception {
this.spring.register(MissingAuthorityConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/profile"))
.andExpect(status().isOk())
.andExpect(authenticated().withUsername("user"));
// @formatter:on
}
@RestController
static class Http200Controller {
@GetMapping("/**")
String ok() {
return "ok";
}
}
}

View File

@ -0,0 +1,37 @@
package org.springframework.security.docs.servlet.authentication.obtainingmoreauthorization;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
import org.springframework.security.web.SecurityFilterChain;
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class ScopeConfiguration {
// tag::httpSecurity[]
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/profile/**").hasAuthority("SCOPE_profile:read")
.anyRequest().authenticated()
)
.x509(Customizer.withDefaults())
.oauth2Login(Customizer.withDefaults());
// @formatter:on
return http.build();
}
// end::httpSecurity[]
@Bean
ClientRegistrationRepository clients() {
return new InMemoryClientRegistrationRepository(TestClientRegistrations.clientRegistration().build());
}
}

View File

@ -0,0 +1,93 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.docs.servlet.authentication.reauthentication;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.config.test.SpringTestContext;
import org.springframework.security.config.test.SpringTestContextExtension;
import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests {@link CustomX509Configuration}.
*
* @author Rob Winch
*/
@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class })
@TestExecutionListeners(WithSecurityContextTestExecutionListener.class)
public class ReauthenticationTests {
public final SpringTestContext spring = new SpringTestContext(this);
@Autowired
MockMvc mockMvc;
@Test
@WithMockUser
void formLoginWhenSimpleConfigurationThenPermits() throws Exception {
this.spring.register(SimpleConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/"))
.andExpect(status().isOk())
.andExpect(authenticated().withUsername("user"));
// @formatter:on
}
@Test
@WithMockUser
void formLoginWhenRequireOttConfigurationThenRedirectsToOtt() throws Exception {
this.spring.register(RequireOttConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/profile"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("http://localhost/login?factor=ott"));
// @formatter:on
}
@Test
@WithMockUser(authorities = "FACTOR_OTT")
void ottWhenRequireOttConfigurationThenAllows() throws Exception {
this.spring.register(RequireOttConfiguration.class, Http200Controller.class).autowire();
// @formatter:off
this.mockMvc.perform(get("/profile"))
.andExpect(status().isOk())
.andExpect(authenticated().withUsername("user"));
// @formatter:on
}
@RestController
static class Http200Controller {
@GetMapping("/**")
String ok() {
return "ok";
}
}
}

View File

@ -0,0 +1,50 @@
package org.springframework.security.docs.servlet.authentication.reauthentication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class RequireOttConfiguration {
// tag::httpSecurity[]
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/profile/**").hasAuthority("FACTOR_OTT") // <1>
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults());
// @formatter:on
return http.build();
}
// end::httpSecurity[]
@Bean
UserDetailsService userDetailsService() {
return new InMemoryUserDetailsManager(
User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.authorities("app")
.build()
);
}
@Bean
OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
}
}

View File

@ -0,0 +1,46 @@
package org.springframework.security.docs.servlet.authentication.reauthentication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class SimpleConfiguration {
// tag::httpSecurity[]
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults());
// @formatter:on
return http.build();
}
// end::httpSecurity[]
@Bean
UserDetailsService userDetailsService() {
return new InMemoryUserDetailsManager(
User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.authorities("app")
.build()
);
}
@Bean
OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
}
}

View File

@ -0,0 +1,119 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.kt.docs.servlet.authentication.authorizationmanagerfactory
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.config.test.SpringTestContext
import org.springframework.security.config.test.SpringTestContextExtension
import org.springframework.security.test.context.support.WithMockUser
import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener
import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
/**
* Tests [CustomX509Configuration].
*
* @author Rob Winch
*/
@ExtendWith(SpringExtension::class, SpringTestContextExtension::class)
@TestExecutionListeners(WithSecurityContextTestExecutionListener::class)
class AuthorizationManagerFactoryTests {
@JvmField
val spring: SpringTestContext = SpringTestContext(this)
@Autowired
var mockMvc: MockMvc? = null
@Test
@WithMockUser(authorities = ["FACTOR_PASSWORD", "FACTOR_OTT"])
@Throws(Exception::class)
fun getWhenAuthenticatedWithPasswordAndOttThenPermits() {
this.spring.register(UseAuthorizationManagerFactoryConfiguration::class.java, Http200Controller::class.java)
.autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user"))
// @formatter:on
}
@Test
@WithMockUser(authorities = ["FACTOR_PASSWORD"])
@Throws(Exception::class)
fun getWhenAuthenticatedWithPasswordThenRedirectsToOtt() {
this.spring.register(UseAuthorizationManagerFactoryConfiguration::class.java, Http200Controller::class.java)
.autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
.andExpect(MockMvcResultMatchers.status().is3xxRedirection())
.andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=ott"))
// @formatter:on
}
@Test
@WithMockUser(authorities = ["FACTOR_OTT"])
@Throws(Exception::class)
fun getWhenAuthenticatedWithOttThenRedirectsToPassword() {
this.spring.register(UseAuthorizationManagerFactoryConfiguration::class.java, Http200Controller::class.java)
.autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
.andExpect(MockMvcResultMatchers.status().is3xxRedirection())
.andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password"))
// @formatter:on
}
@Test
@WithMockUser
@Throws(Exception::class)
fun getWhenAuthenticatedThenRedirectsToPassword() {
this.spring.register(UseAuthorizationManagerFactoryConfiguration::class.java, Http200Controller::class.java)
.autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
.andExpect(MockMvcResultMatchers.status().is3xxRedirection())
.andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password"))
// @formatter:on
}
@Test
@Throws(Exception::class)
fun getWhenUnauthenticatedThenRedirectsToBoth() {
this.spring.register(UseAuthorizationManagerFactoryConfiguration::class.java, Http200Controller::class.java)
.autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
.andExpect(MockMvcResultMatchers.status().is3xxRedirection())
.andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login"))
// @formatter:on
}
@RestController
internal class Http200Controller {
@GetMapping("/**")
fun ok(): String {
return "ok"
}
}
}

View File

@ -0,0 +1,53 @@
package org.springframework.security.kt.docs.servlet.authentication.authorizationmanagerfactory
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.invoke
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
class ListAuthoritiesEverywhereConfiguration {
// tag::httpSecurity[]
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
// @formatter:off
http {
authorizeHttpRequests {
authorize("/admin/**", hasAllAuthorities("FACTOR_PASSWORD", "FACTOR_OTT", "ROLE_ADMIN")) // <1>
authorize(anyRequest, hasAllAuthorities("FACTOR_PASSWORD", "FACTOR_OTT"))
}
formLogin { }
oneTimeTokenLogin { }
}
// @formatter:on
return http.build()
}
// end::httpSecurity[]
// end::httpSecurity[]
@Bean
fun userDetailsService(): UserDetailsService {
return InMemoryUserDetailsManager(
User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.authorities("app")
.build()
)
}
@Bean
fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler {
return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
}
}

View File

@ -0,0 +1,60 @@
package org.springframework.security.kt.docs.servlet.authentication.authorizationmanagerfactory
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.authorization.AuthorizationManagerFactory
import org.springframework.security.authorization.DefaultAuthorizationManagerFactory
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.invoke
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
internal class UseAuthorizationManagerFactoryConfiguration {
// tag::httpSecurity[]
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
// @formatter:off
http {
authorizeHttpRequests {
authorize("/admin/**", hasRole("ADMIN"))
authorize(anyRequest, authenticated)
}
formLogin { }
oneTimeTokenLogin { }
}
// @formatter:on
return http.build()
}
// end::httpSecurity[]
// tag::authorizationManagerFactoryBean[]
@Bean
fun authz(): AuthorizationManagerFactory<Object> {
return DefaultAuthorizationManagerFactory.builder<Object>()
.requireAdditionalAuthorities("FACTOR_PASSWORD", "FACTOR_OTT").build()
}
// end::authorizationManagerFactoryBean[]
@Bean
fun userDetailsService(): UserDetailsService {
return InMemoryUserDetailsManager(
User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.authorities("app")
.build()
)
}
@Bean
fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler {
return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
}
}

View File

@ -0,0 +1,95 @@
package org.springframework.security.kt.docs.servlet.authentication.customauthorizationmanagerfactory
import org.jspecify.annotations.NullMarked
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.access.expression.SecurityExpressionRoot
import org.springframework.security.authorization.*
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.invoke
import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.AuthorityUtils
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
import org.springframework.stereotype.Component
import java.util.function.Supplier
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
internal class CustomAuthorizationManagerFactory {
// tag::httpSecurity[]
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
// @formatter:off
http {
authorizeHttpRequests {
authorize("/admin/**", hasRole("ADMIN"))
authorize(anyRequest, authenticated)
}
formLogin { }
oneTimeTokenLogin { }
}
// @formatter:on
return http.build()
}
// end::httpSecurity[]
// tag::authorizationManager[]
@Component
internal open class OptInToMfaAuthorizationManager : AuthorizationManager<Object> {
override fun authorize(
authentication: Supplier<out Authentication?>, context: Object): AuthorizationResult {
val principal = authentication.get().getPrincipal() as MyPrincipal?
if (principal!!.optedIn) {
val root = object : SecurityExpressionRoot<Object>(authentication, context) { }
return AuthorityAuthorizationDecision(
root.hasAuthority("FACTOR_OTT"),
AuthorityUtils.createAuthorityList("FACTOR_OTT")
)
}
return AuthorizationDecision(true)
}
}
// end::authorizationManager[]
// tag::authorizationManagerFactory[]
@Bean
fun authorizationManagerFactory(optIn: OptInToMfaAuthorizationManager?): AuthorizationManagerFactory<Object> {
val defaults = DefaultAuthorizationManagerFactory<Object>()
defaults.setAdditionalAuthorization(optIn)
return defaults
}
// end::authorizationManagerFactory[]
@NullMarked
class MyPrincipal(val user: String, val optedIn: Boolean) : UserDetails {
override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
return AuthorityUtils.createAuthorityList("app")
}
override fun getPassword(): String? {
return null
}
override fun getUsername(): String {
return this.user
}
}
@Bean
fun users(): UserDetailsService {
return UserDetailsService { username: String? -> MyPrincipal(username!!, username == "optedin") }
}
@Bean
fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler {
return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
}
}

View File

@ -0,0 +1,93 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.kt.docs.servlet.authentication.customauthorizationmanagerfactory
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.authentication.TestingAuthenticationToken
import org.springframework.security.config.test.SpringTestContext
import org.springframework.security.config.test.SpringTestContextExtension
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
/**
* Tests [CustomX509Configuration].
*
* @author Rob Winch
*/
@ExtendWith(SpringTestContextExtension::class)
class CustomAuthorizationManagerFactoryTests {
@JvmField
val spring: SpringTestContext = SpringTestContext(this)
@Autowired
var mockMvc: MockMvc? = null
@Autowired
var users: UserDetailsService? = null
@Test
@Throws(Exception::class)
fun getWhenOptedInThenRedirectsToOtt() {
this.spring.register(CustomAuthorizationManagerFactory::class.java, Http200Controller::class.java).autowire()
val user = this.users!!.loadUserByUsername("optedin")
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/").with(SecurityMockMvcRequestPostProcessors.user(user)))
.andExpect(MockMvcResultMatchers.status().is3xxRedirection())
.andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=ott"))
// @formatter:on
}
@Test
@Throws(Exception::class)
fun getWhenNotOptedInThenAllows() {
this.spring.register(CustomAuthorizationManagerFactory::class.java, Http200Controller::class.java).autowire()
val user = this.users!!.loadUserByUsername("user")
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/").with(SecurityMockMvcRequestPostProcessors.user(user)))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user"))
// @formatter:on
}
@Test
@Throws(Exception::class)
fun getWhenOptedAndHasFactorThenAllows() {
this.spring.register(CustomAuthorizationManagerFactory::class.java, Http200Controller::class.java).autowire()
val user = this.users!!.loadUserByUsername("optedin")
val token = TestingAuthenticationToken(user, "", "FACTOR_OTT")
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/").with(SecurityMockMvcRequestPostProcessors.authentication(token)))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("optedin"))
// @formatter:on
}
@RestController
internal class Http200Controller {
@GetMapping("/**")
fun ok(): String {
return "ok"
}
}
}

View File

@ -0,0 +1,52 @@
package org.springframework.security.kt.docs.servlet.authentication.multifactorauthentication
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.invoke
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
internal class ListAuthoritiesConfiguration {
// tag::httpSecurity[]
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
// @formatter:off
http {
authorizeHttpRequests {
authorize(anyRequest, hasAllAuthorities("FACTOR_PASSWORD", "FACTOR_OTT"))
}
formLogin { }
oneTimeTokenLogin { }
}
// @formatter:on
return http.build()
}
// end::httpSecurity[]
// end::httpSecurity[]
@Bean
fun userDetailsService(): UserDetailsService {
return InMemoryUserDetailsManager(
User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.authorities("app")
.build()
)
}
@Bean
fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler {
return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
}
}

View File

@ -0,0 +1,114 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.kt.docs.servlet.authentication.multifactorauthentication
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.config.test.SpringTestContext
import org.springframework.security.config.test.SpringTestContextExtension
import org.springframework.security.test.context.support.WithMockUser
import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener
import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
/**
* Tests [CustomX509Configuration].
*
* @author Rob Winch
*/
@ExtendWith(SpringExtension::class, SpringTestContextExtension::class)
@TestExecutionListeners(WithSecurityContextTestExecutionListener::class)
class MultiFactorAuthenticationTests {
@JvmField
val spring: SpringTestContext = SpringTestContext(this)
@Autowired
var mockMvc: MockMvc? = null
@Test
@WithMockUser(authorities = ["FACTOR_PASSWORD", "FACTOR_OTT"])
@Throws(Exception::class)
fun getWhenAuthenticatedWithPasswordAndOttThenPermits() {
this.spring.register(ListAuthoritiesConfiguration::class.java, Http200Controller::class.java).autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user"))
// @formatter:on
}
@Test
@WithMockUser(authorities = ["FACTOR_PASSWORD"])
@Throws(Exception::class)
fun getWhenAuthenticatedWithPasswordThenRedirectsToOtt() {
this.spring.register(ListAuthoritiesConfiguration::class.java, Http200Controller::class.java).autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
.andExpect(MockMvcResultMatchers.status().is3xxRedirection())
.andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=ott"))
// @formatter:on
}
@Test
@WithMockUser(authorities = ["FACTOR_OTT"])
@Throws(Exception::class)
fun getWhenAuthenticatedWithOttThenRedirectsToPassword() {
this.spring.register(ListAuthoritiesConfiguration::class.java, Http200Controller::class.java).autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
.andExpect(MockMvcResultMatchers.status().is3xxRedirection())
.andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password"))
// @formatter:on
}
@Test
@WithMockUser
@Throws(Exception::class)
fun getWhenAuthenticatedThenRedirectsToPassword() {
this.spring.register(ListAuthoritiesConfiguration::class.java, Http200Controller::class.java).autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
.andExpect(MockMvcResultMatchers.status().is3xxRedirection())
.andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password"))
// @formatter:on
}
@Test
@Throws(Exception::class)
fun getWhenUnauthenticatedThenRedirectsToBoth() {
this.spring.register(ListAuthoritiesConfiguration::class.java, Http200Controller::class.java).autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
.andExpect(MockMvcResultMatchers.status().is3xxRedirection())
.andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login"))
// @formatter:on
}
@RestController
internal class Http200Controller {
@GetMapping("/**")
fun ok(): String {
return "ok"
}
}
}

View File

@ -0,0 +1,129 @@
package org.springframework.security.kt.docs.servlet.authentication.obtainingmoreauthorization
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.authorization.AllAuthoritiesAuthorizationManager.hasAllAuthorities
import org.springframework.security.authorization.AuthorizationDecision
import org.springframework.security.authorization.AuthorizationManager
import org.springframework.security.authorization.AuthorizationManagerFactory
import org.springframework.security.authorization.AuthorizationManagers.allOf
import org.springframework.security.authorization.DefaultAuthorizationManagerFactory
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer
import org.springframework.security.config.annotation.web.invoke
import org.springframework.security.core.AuthenticationException
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
import org.springframework.security.oauth2.client.registration.TestClientRegistrations
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.DefaultSecurityFilterChain
import org.springframework.security.web.access.intercept.RequestAuthorizationContext
import org.springframework.stereotype.Component
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
internal class MissingAuthorityConfiguration {
// tag::httpSecurity[]
@Bean
fun securityFilterChain(http: HttpSecurity, oauth2: ScopeRetrievingAuthenticationEntryPoint): DefaultSecurityFilterChain? {
http {
authorizeHttpRequests {
authorize("/profile/**", hasAuthority("SCOPE_profile:read"))
authorize(anyRequest, authenticated)
}
x509 { }
oauth2Login { }
}
http.exceptionHandling { e: ExceptionHandlingConfigurer<HttpSecurity> -> e
.defaultDeniedHandlerForMissingAuthority(oauth2, "SCOPE_profile:read")
}
return http.build()
}
// end::httpSecurity[]
// tag::authenticationEntryPoint[]
@Component
internal class ScopeRetrievingAuthenticationEntryPoint : AuthenticationEntryPoint {
override fun commence(request: HttpServletRequest, response: HttpServletResponse, authException: AuthenticationException) {
response.sendRedirect("https://authz.example.org/authorize?scope=profile:read")
}
}
// end::authenticationEntryPoint[]
// tag::authorizationManagerFactoryBean[]
@Bean
fun authz(): AuthorizationManagerFactory<RequestAuthorizationContext> {
return FactorAuthorizationManagerFactory(hasAllAuthorities("FACTOR_X509", "FACTOR_AUTHORIZATION_CODE"))
}
// end::authorizationManagerFactoryBean[]
// tag::authorizationManagerFactory[]
internal inner class FactorAuthorizationManagerFactory(private val hasAuthorities: AuthorizationManager<RequestAuthorizationContext>) :
AuthorizationManagerFactory<RequestAuthorizationContext> {
private val delegate = DefaultAuthorizationManagerFactory<RequestAuthorizationContext>()
override fun permitAll(): AuthorizationManager<RequestAuthorizationContext> {
return this.delegate.permitAll()
}
override fun denyAll(): AuthorizationManager<RequestAuthorizationContext> {
return this.delegate.denyAll()
}
override fun hasRole(role: String): AuthorizationManager<RequestAuthorizationContext> {
return hasAnyRole(role)
}
override fun hasAnyRole(vararg roles: String): AuthorizationManager<RequestAuthorizationContext> {
return addFactors(this.delegate.hasAnyRole(*roles))
}
override fun hasAllRoles(vararg roles: String): AuthorizationManager<RequestAuthorizationContext> {
return addFactors(this.delegate.hasAllRoles(*roles))
}
override fun hasAuthority(authority: String): AuthorizationManager<RequestAuthorizationContext> {
return hasAnyAuthority(authority)
}
override fun hasAnyAuthority(vararg authorities: String): AuthorizationManager<RequestAuthorizationContext> {
return addFactors(this.delegate.hasAnyAuthority(*authorities))
}
override fun hasAllAuthorities(vararg authorities: String): AuthorizationManager<RequestAuthorizationContext> {
return addFactors(this.delegate.hasAllAuthorities(*authorities))
}
override fun authenticated(): AuthorizationManager<RequestAuthorizationContext> {
return addFactors(this.delegate.authenticated())
}
override fun fullyAuthenticated(): AuthorizationManager<RequestAuthorizationContext> {
return addFactors(this.delegate.fullyAuthenticated())
}
override fun rememberMe(): AuthorizationManager<RequestAuthorizationContext> {
return addFactors(this.delegate.rememberMe())
}
override fun anonymous(): AuthorizationManager<RequestAuthorizationContext> {
return this.delegate.anonymous()
}
private fun addFactors(delegate: AuthorizationManager<RequestAuthorizationContext>): AuthorizationManager<RequestAuthorizationContext> {
return allOf(AuthorizationDecision(false), this.hasAuthorities, delegate)
}
}
// end::authorizationManagerFactory[]
// end::authenticationEntryPoint[]
@Bean
fun clients(): ClientRegistrationRepository {
return InMemoryClientRegistrationRepository(TestClientRegistrations.clientRegistration().build())
}
}

View File

@ -0,0 +1,104 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.kt.docs.servlet.authentication.obtainingmoreauthorization
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.config.test.SpringTestContext
import org.springframework.security.config.test.SpringTestContextExtension
import org.springframework.security.docs.servlet.authentication.obtainingmoreauthorization.ScopeConfiguration
import org.springframework.security.test.context.support.WithMockUser
import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener
import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
/**
* Tests [CustomX509Configuration].
*
* @author Rob Winch
*/
@ExtendWith(SpringExtension::class, SpringTestContextExtension::class)
@TestExecutionListeners(WithSecurityContextTestExecutionListener::class)
class ObtainingMoreAuthorizationTests {
@JvmField
val spring: SpringTestContext = SpringTestContext(this)
@Autowired
var mockMvc: MockMvc? = null
@Test
@WithMockUser
@Throws(Exception::class)
fun profileWhenScopeConfigurationThenDenies() {
this.spring.register(ScopeConfiguration::class.java, Http200Controller::class.java).autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile"))
.andExpect(MockMvcResultMatchers.status().isForbidden())
// @formatter:on
}
@Test
@WithMockUser(authorities = ["FACTOR_X509", "FACTOR_AUTHORIZATION_CODE"])
@Throws(Exception::class)
fun profileWhenMissingAuthorityConfigurationThenRedirectsToAuthorizationServer() {
this.spring.register(MissingAuthorityConfiguration::class.java, Http200Controller::class.java).autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile"))
.andExpect(MockMvcResultMatchers.status().is3xxRedirection())
.andExpect(MockMvcResultMatchers.redirectedUrl("https://authz.example.org/authorize?scope=profile:read"))
// @formatter:on
}
@Test
@WithMockUser(authorities = ["SCOPE_profile:read"])
@Throws(Exception::class)
fun profileWhenMissingX509WithOttThenForbidden() {
this.spring.register(MissingAuthorityConfiguration::class.java, Http200Controller::class.java).autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile"))
.andExpect(MockMvcResultMatchers.status().isForbidden())
// @formatter:on
}
@Test
@WithMockUser(authorities = ["FACTOR_X509", "FACTOR_AUTHORIZATION_CODE", "SCOPE_profile:read"])
@Throws(
Exception::class
)
fun profileWhenAuthenticatedAndHasScopeThenPermits() {
this.spring.register(MissingAuthorityConfiguration::class.java, Http200Controller::class.java).autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile"))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user"))
// @formatter:on
}
@RestController
internal class Http200Controller {
@GetMapping("/**")
fun ok(): String {
return "ok"
}
}
}

View File

@ -0,0 +1,38 @@
package org.springframework.security.kt.docs.servlet.authentication.obtainingmoreauthorization
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.invoke
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
import org.springframework.security.oauth2.client.registration.TestClientRegistrations
import org.springframework.security.web.SecurityFilterChain
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
class ScopeConfiguration {
// tag::httpSecurity[]
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
// @formatter:off
http {
authorizeHttpRequests {
authorize("/profile/**", hasAuthority("SCOPE_profile:read"))
authorize(anyRequest, authenticated)
}
x509 { }
oauth2Login { }
}
// @formatter:on
return http.build()
}
// end::httpSecurity[]
// end::httpSecurity[]
@Bean
fun clients(): ClientRegistrationRepository {
return InMemoryClientRegistrationRepository(TestClientRegistrations.clientRegistration().build())
}
}

View File

@ -0,0 +1,93 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.kt.docs.servlet.authentication.reauthentication
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.config.test.SpringTestContext
import org.springframework.security.config.test.SpringTestContextExtension
import org.springframework.security.docs.servlet.authentication.reauthentication.RequireOttConfiguration
import org.springframework.security.docs.servlet.authentication.reauthentication.SimpleConfiguration
import org.springframework.security.test.context.support.WithMockUser
import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener
import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
/**
* Tests [CustomX509Configuration].
*
* @author Rob Winch
*/
@ExtendWith(SpringExtension::class, SpringTestContextExtension::class)
@TestExecutionListeners(WithSecurityContextTestExecutionListener::class)
class ReauthenticationTests {
@JvmField
val spring: SpringTestContext = SpringTestContext(this)
@Autowired
var mockMvc: MockMvc? = null
@Test
@WithMockUser
@Throws(Exception::class)
fun formLoginWhenSimpleConfigurationThenPermits() {
this.spring.register(SimpleConfiguration::class.java, Http200Controller::class.java).autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user"))
// @formatter:on
}
@Test
@WithMockUser
@Throws(Exception::class)
fun formLoginWhenRequireOttConfigurationThenRedirectsToOtt() {
this.spring.register(RequireOttConfiguration::class.java, Http200Controller::class.java).autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile"))
.andExpect(MockMvcResultMatchers.status().is3xxRedirection())
.andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=ott"))
// @formatter:on
}
@Test
@WithMockUser(authorities = ["FACTOR_OTT"])
@Throws(Exception::class)
fun ottWhenRequireOttConfigurationThenAllows() {
this.spring.register(RequireOttConfiguration::class.java, Http200Controller::class.java).autowire()
// @formatter:off
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile"))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user"))
// @formatter:on
}
@RestController
internal class Http200Controller {
@GetMapping("/**")
fun ok(): String {
return "ok"
}
}
}

View File

@ -0,0 +1,52 @@
package org.springframework.security.kt.docs.servlet.authentication.reauthentication
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.invoke
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
class RequireOttConfiguration {
// tag::httpSecurity[]
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
// @formatter:off
http {
authorizeHttpRequests {
authorize("/profile/**", hasAuthority("FACTOR_OTT")) // <1>
authorize(anyRequest, authenticated)
}
formLogin { }
oneTimeTokenLogin { }
}
// @formatter:on
return http.build()
}
// end::httpSecurity[]
// end::httpSecurity[]
@Bean
fun userDetailsService(): UserDetailsService {
return InMemoryUserDetailsManager(
User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.authorities("app")
.build()
)
}
@Bean
fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler {
return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
}
}

View File

@ -0,0 +1,50 @@
package org.springframework.security.kt.docs.servlet.authentication.reauthentication
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.invoke
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
class SimpleConfiguration {
// tag::httpSecurity[]
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
// @formatter:off
http {
authorizeHttpRequests {
authorize(anyRequest, authenticated)
}
formLogin { }
oneTimeTokenLogin { }
}
// @formatter:on
return http.build()
}
// end::httpSecurity[]
// end::httpSecurity[]
@Bean
fun userDetailsService(): UserDetailsService {
return InMemoryUserDetailsManager(
User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.authorities("app")
.build()
)
}
@Bean
fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler {
return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
}
}

View File

@ -16,6 +16,9 @@
package org.springframework.security.web;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.access.WebInvocationPrivilegeEvaluator;
/**
@ -52,6 +55,17 @@ public final class WebAttributes {
public static final String WEB_INVOCATION_PRIVILEGE_EVALUATOR_ATTRIBUTE = WebAttributes.class.getName()
+ ".WEB_INVOCATION_PRIVILEGE_EVALUATOR_ATTRIBUTE";
/**
* Used to set a {@code Collection} of {@link GrantedAuthority} instances into the
* {@link HttpServletRequest}.
* <p>
* Represents what authorities are missing to be authorized for the current request
*
* @since 7.0
* @see org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler
*/
public static final String MISSING_AUTHORITIES = WebAttributes.class + ".MISSING_AUTHORITIES";
private WebAttributes() {
}

View File

@ -0,0 +1,209 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.access;
import java.io.IOException;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.Nullable;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.authorization.AuthorityAuthorizationDecision;
import org.springframework.security.authorization.AuthorizationDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.WebAttributes;
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.util.ThrowableAnalyzer;
import org.springframework.security.web.util.matcher.AnyRequestMatcher;
import org.springframework.util.Assert;
/**
* An {@link AccessDeniedHandler} that adapts {@link AuthenticationEntryPoint}s based on
* missing {@link GrantedAuthority}s. These authorities are specified in an
* {@link AuthorityAuthorizationDecision} inside an {@link AuthorizationDeniedException}.
*
* <p>
* This is helpful in adaptive authentication scenarios where an
* {@link org.springframework.security.authorization.AuthorizationManager} indicates
* additional authorities needed to access a given resource.
* </p>
*
* <p>
* For example, if an
* {@link org.springframework.security.authorization.AuthorizationManager} states that to
* access the home page, the user needs the {@code FACTOR_OTT} authority, then this
* handler can be configured in the following way to redirect to the one-time-token login
* page:
* </p>
*
* <code>
* AccessDeniedHandler handler = DelegatingMissingAuthorityAccessDeniedHandler.builder()
* .addEntryPointFor(new LoginUrlAuthenticationEntryPoint("/login"), "FACTOR_OTT")
* .addEntryPointFor(new MyCustomEntryPoint(), "FACTOR_PASSWORD")
* .build();
* </code>
*
* @author Josh Cummings
* @since 7.0
* @see AuthorizationDeniedException
* @see AuthorityAuthorizationDecision
* @see org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer
*/
public final class DelegatingMissingAuthorityAccessDeniedHandler implements AccessDeniedHandler {
private final ThrowableAnalyzer throwableAnalyzer = new ThrowableAnalyzer();
private final Map<String, AuthenticationEntryPoint> entryPoints;
private RequestCache requestCache = new NullRequestCache();
private AccessDeniedHandler defaultAccessDeniedHandler = new AccessDeniedHandlerImpl();
private DelegatingMissingAuthorityAccessDeniedHandler(Map<String, AuthenticationEntryPoint> entryPoints) {
Assert.notEmpty(entryPoints, "entryPoints cannot be empty");
this.entryPoints = entryPoints;
}
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException denied)
throws IOException, ServletException {
Collection<GrantedAuthority> authorities = missingAuthorities(denied);
for (GrantedAuthority needed : authorities) {
AuthenticationEntryPoint entryPoint = this.entryPoints.get(needed.getAuthority());
if (entryPoint == null) {
continue;
}
this.requestCache.saveRequest(request, response);
request.setAttribute(WebAttributes.MISSING_AUTHORITIES, 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.defaultAccessDeniedHandler.handle(request, response, denied);
}
/**
* Use this {@link AccessDeniedHandler} for {@link AccessDeniedException}s that this
* handler doesn't support. By default, this uses {@link AccessDeniedHandlerImpl}.
* @param defaultAccessDeniedHandler the default {@link AccessDeniedHandler} to use
*/
public void setDefaultAccessDeniedHandler(AccessDeniedHandler defaultAccessDeniedHandler) {
Assert.notNull(defaultAccessDeniedHandler, "defaultAccessDeniedHandler cannot be null");
this.defaultAccessDeniedHandler = defaultAccessDeniedHandler;
}
/**
* Use this {@link RequestCache} to remember the current request.
* <p>
* Uses {@link NullRequestCache} by default
* </p>
* @param requestCache the {@link RequestCache} to use
*/
public void setRequestCache(RequestCache requestCache) {
Assert.notNull(requestCache, "requestCachgrantedaue cannot be null");
this.requestCache = requestCache;
}
private Collection<GrantedAuthority> missingAuthorities(AccessDeniedException ex) {
AuthorizationDeniedException denied = findAuthorizationDeniedException(ex);
if (denied == null) {
return List.of();
}
if (!(denied.getAuthorizationResult() instanceof AuthorityAuthorizationDecision authorization)) {
return List.of();
}
return authorization.getAuthorities();
}
private @Nullable AuthorizationDeniedException findAuthorizationDeniedException(AccessDeniedException ex) {
if (ex instanceof AuthorizationDeniedException denied) {
return denied;
}
Throwable[] chain = this.throwableAnalyzer.determineCauseChain(ex);
return (AuthorizationDeniedException) this.throwableAnalyzer
.getFirstThrowableOfType(AuthorizationDeniedException.class, chain);
}
public static Builder builder() {
return new Builder();
}
/**
* A builder for configuring the set of authority/entry-point pairs
*
* @author Josh Cummings
* @since 7.0
*/
public static final class Builder {
private final Map<String, DelegatingAuthenticationEntryPoint.Builder> entryPointBuilderByAuthority = new LinkedHashMap<>();
private Builder() {
}
/**
* Use this {@link AuthenticationEntryPoint} when the given
* {@code missingAuthority} is missing from the authenticated user
* @param entryPoint the {@link AuthenticationEntryPoint} for the given authority
* @param missingAuthority the authority
* @return the {@link Builder} for further configurations
*/
public Builder addEntryPointFor(AuthenticationEntryPoint entryPoint, String missingAuthority) {
DelegatingAuthenticationEntryPoint.Builder builder = DelegatingAuthenticationEntryPoint.builder()
.addEntryPointFor(entryPoint, AnyRequestMatcher.INSTANCE);
this.entryPointBuilderByAuthority.put(missingAuthority, builder);
return this;
}
/**
* Use this {@link AuthenticationEntryPoint} when the given
* {@code missingAuthority} is missing from the authenticated user
* @param entryPoint a consumer to configure the underlying
* {@link DelegatingAuthenticationEntryPoint}
* @param missingAuthority the authority
* @return the {@link Builder} for further configurations
*/
public Builder addEntryPointFor(Consumer<DelegatingAuthenticationEntryPoint.Builder> entryPoint,
String missingAuthority) {
entryPoint.accept(this.entryPointBuilderByAuthority.computeIfAbsent(missingAuthority,
(k) -> DelegatingAuthenticationEntryPoint.builder()));
return this;
}
public DelegatingMissingAuthorityAccessDeniedHandler build() {
Map<String, AuthenticationEntryPoint> entryPointByAuthority = new LinkedHashMap<>();
this.entryPointBuilderByAuthority.forEach((key, value) -> entryPointByAuthority.put(key, value.build()));
return new DelegatingMissingAuthorityAccessDeniedHandler(entryPointByAuthority);
}
}
}

View File

@ -196,7 +196,8 @@ public class ExceptionTranslationFilter extends GenericFilterBean implements Mes
}
AuthenticationException ex = new InsufficientAuthenticationException(
this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
"Full authentication is required to access this resource"));
"Full authentication is required to access this resource"),
exception);
ex.setAuthenticationRequest(authentication);
sendStartAuthentication(request, response, chain, ex);
}

View File

@ -17,6 +17,8 @@
package org.springframework.security.web.authentication;
import java.io.IOException;
import java.util.Collection;
import java.util.Locale;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
@ -30,16 +32,20 @@ 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;
import org.springframework.security.web.PortMapperImpl;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.WebAttributes;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.security.web.util.RedirectUrlBuilder;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.util.UriComponentsBuilder;
/**
* Used by the {@link ExceptionTranslationFilter} to commence a form login authentication
@ -68,6 +74,8 @@ public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoin
private static final Log logger = LogFactory.getLog(LoginUrlAuthenticationEntryPoint.class);
private static final String FACTOR_PREFIX = "FACTOR_";
private PortMapper portMapper = new PortMapperImpl();
private String loginFormUrl;
@ -107,9 +115,29 @@ public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoin
* @param exception the exception
* @return the URL (cannot be null or empty; defaults to {@link #getLoginFormUrl()})
*/
@SuppressWarnings("unchecked")
protected String determineUrlToUseForThisRequest(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) {
return getLoginFormUrl();
Collection<GrantedAuthority> authorities = getAttribute(request, WebAttributes.MISSING_AUTHORITIES,
Collection.class);
if (CollectionUtils.isEmpty(authorities)) {
return getLoginFormUrl();
}
Collection<String> factors = authorities.stream()
.filter((a) -> a.getAuthority().startsWith(FACTOR_PREFIX))
.map((a) -> a.getAuthority().substring(FACTOR_PREFIX.length()).toLowerCase(Locale.ROOT))
.toList();
return UriComponentsBuilder.fromUriString(getLoginFormUrl()).queryParam("factor", factors).toUriString();
}
private static <T> @Nullable T getAttribute(HttpServletRequest request, String name, Class<T> clazz) {
Object value = request.getAttribute(name);
if (value == null) {
return null;
}
String message = String.format("Found %s in %s, but expecting a %s", value.getClass(), name, clazz);
Assert.isInstanceOf(clazz, value, message);
return (T) value;
}
/**

View File

@ -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
@ -52,6 +59,9 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
public static final String ERROR_PARAMETER_NAME = "error";
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
.getContextHolderStrategy();
private @Nullable String loginPageUrl;
private @Nullable String logoutSuccessUrl;
@ -78,6 +88,10 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
private @Nullable String rememberMeParameter;
private final String factorParameter = "factor";
private final Collection<String> allowedParameters = List.of(this.factorParameter);
@SuppressWarnings("NullAway.Init")
private Map<String, String> oauth2AuthenticationUrlToClientName;
@ -109,6 +123,18 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
}
}
/**
* Use this {@link SecurityContextHolderStrategy} to retrieve authenticated users.
* <p>
* Uses {@link SecurityContextHolder#getContextHolderStrategy()} by default.
* @param securityContextHolderStrategy the strategy to use
* @since 7.0
*/
public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) {
Assert.notNull(securityContextHolderStrategy, "securityContextHolderStrategy cannot be null");
this.securityContextHolderStrategy = securityContextHolderStrategy;
}
/**
* Sets a Function used to resolve a Map of the hidden inputs where the key is the
* name of the input and the value is the value of the input. Typically this is used
@ -223,16 +249,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("webauthn")) {
builder.withRawHtml("javaScript", renderJavaScript(request, contextPath))
.withRawHtml("passkeyLogin", renderPasskeyLogin());
}
if (wantsAuthority.test("password")) {
builder.withRawHtml("formLogin",
renderFormLogin(request, loginError, logoutSuccess, contextPath, errorMsg));
}
if (wantsAuthority.test("ott")) {
builder.withRawHtml("oneTimeTokenLogin",
renderOneTimeTokenLogin(request, loginError, logoutSuccess, contextPath, errorMsg));
}
if (wantsAuthority.test("authorization_code")) {
builder.withRawHtml("oauth2Login", renderOAuth2Login(loginError, logoutSuccess, errorMsg, contextPath));
}
if (wantsAuthority.test("saml_response")) {
builder.withRawHtml("saml2Login", renderSaml2Login(loginError, logoutSuccess, errorMsg, contextPath));
}
return builder.render();
}
private Predicate<String> wantsAuthority(HttpServletRequest request) {
String[] authorities = request.getParameterValues(this.factorParameter);
if (authorities == null) {
return (authority) -> true;
}
return List.of(authorities)::contains;
}
private String renderJavaScript(HttpServletRequest request, String contextPath) {
@ -271,6 +324,13 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
return "";
}
String username = getUsername();
String usernameInput = ((username != null)
? HtmlTemplates.fromTemplate(FORM_READONLY_USERNAME_INPUT).withValue("username", username)
: HtmlTemplates.fromTemplate(FORM_USERNAME_INPUT))
.withValue("usernameParameter", this.usernameParameter)
.render();
String hiddenInputs = this.resolveHiddenInputs.apply(request)
.entrySet()
.stream()
@ -281,7 +341,7 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
.withValue("loginUrl", contextPath + this.authenticationUrl)
.withRawHtml("errorMessage", renderError(loginError, errorMsg))
.withRawHtml("logoutMessage", renderSuccess(logoutSuccess))
.withValue("usernameParameter", this.usernameParameter)
.withRawHtml("usernameInput", usernameInput)
.withValue("passwordParameter", this.passwordParameter)
.withRawHtml("rememberMeInput", renderRememberMe(this.rememberMeParameter))
.withRawHtml("hiddenInputs", hiddenInputs)
@ -301,11 +361,17 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
.map((inputKeyValue) -> renderHiddenInput(inputKeyValue.getKey(), inputKeyValue.getValue()))
.collect(Collectors.joining("\n"));
String username = getUsername();
String usernameInput = (username != null)
? HtmlTemplates.fromTemplate(ONE_TIME_READONLY_USERNAME_INPUT).withValue("username", username).render()
: ONE_TIME_USERNAME_INPUT;
return HtmlTemplates.fromTemplate(ONE_TIME_TEMPLATE)
.withValue("generateOneTimeTokenUrl", contextPath + this.generateOneTimeTokenUrl)
.withRawHtml("errorMessage", renderError(loginError, errorMsg))
.withRawHtml("logoutMessage", renderSuccess(logoutSuccess))
.withRawHtml("hiddenInputs", hiddenInputs)
.withRawHtml("usernameInput", usernameInput)
.render();
}
@ -374,6 +440,14 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
.render();
}
private @Nullable String getUsername() {
Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
return authentication.getName();
}
return null;
}
private boolean isLogoutSuccess(HttpServletRequest request) {
return this.logoutSuccessUrl != null && matches(request, this.logoutSuccessUrl);
}
@ -413,10 +487,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 = """
@ -466,7 +549,7 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
{{errorMessage}}{{logoutMessage}}
<p>
<label for="username" class="screenreader">Username</label>
<input type="text" id="username" name="{{usernameParameter}}" placeholder="Username" required autofocus>
{{usernameInput}}
</p>
<p>
<label for="password" class="screenreader">Password</label>
@ -477,6 +560,14 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
<button type="submit" class="primary">Sign in</button>
</form>""";
private static final String FORM_READONLY_USERNAME_INPUT = """
<input type="text" id="username" name="{{usernameParameter}}" value="{{username}}" placeholder="Username" required readonly>
""";
private static final String FORM_USERNAME_INPUT = """
<input type="text" id="username" name="{{usernameParameter}}" placeholder="Username" required autofocus>
""";
private static final String HIDDEN_HTML_INPUT_TEMPLATE = """
<input name="{{name}}" type="hidden" value="{{value}}" />
""";
@ -509,11 +600,19 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
{{errorMessage}}{{logoutMessage}}
<p>
<label for="ott-username" class="screenreader">Username</label>
<input type="text" id="ott-username" name="username" placeholder="Username" required>
{{usernameInput}}
</p>
{{hiddenInputs}}
<button class="primary" type="submit" form="ott-form">Send Token</button>
</form>
""";
private static final String ONE_TIME_READONLY_USERNAME_INPUT = """
<input type="text" id="ott-username" name="username" value="{{username}}" placeholder="Username" required readonly>
""";
private static final String ONE_TIME_USERNAME_INPUT = """
<input type="text" id="ott-username" name="username" placeholder="Username" required>
""";
}

View File

@ -0,0 +1,145 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.access;
import java.util.Collection;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authorization.AuthorityAuthorizationDecision;
import org.springframework.security.authorization.AuthorizationDeniedException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
@ExtendWith(MockitoExtension.class)
class DelegatingMissingAuthorityAccessDeniedHandlerTests {
DelegatingMissingAuthorityAccessDeniedHandler.Builder builder;
MockHttpServletRequest request;
MockHttpServletResponse response;
@Mock
AuthenticationEntryPoint factorEntryPoint;
@Mock
AccessDeniedHandler defaultAccessDeniedHandler;
@BeforeEach
void setUp() {
this.builder = DelegatingMissingAuthorityAccessDeniedHandler.builder();
this.builder.addEntryPointFor(this.factorEntryPoint, "FACTOR");
this.request = new MockHttpServletRequest();
this.response = new MockHttpServletResponse();
}
@Test
void whenKnownAuthorityThenCommences() throws Exception {
AccessDeniedHandler accessDeniedHandler = this.builder.build();
accessDeniedHandler.handle(this.request, this.response, missingAuthorities("FACTOR"));
verify(this.factorEntryPoint).commence(any(), any(), any());
}
@Test
void whenUnknownAuthorityThenDefaultCommences() throws Exception {
DelegatingMissingAuthorityAccessDeniedHandler accessDeniedHandler = this.builder.build();
accessDeniedHandler.setDefaultAccessDeniedHandler(this.defaultAccessDeniedHandler);
accessDeniedHandler.handle(this.request, this.response, missingAuthorities("ROLE_USER"));
verify(this.defaultAccessDeniedHandler).handle(any(), any(), any());
verifyNoInteractions(this.factorEntryPoint);
}
@Test
void whenNoAuthoritiesFoundThenDefaultCommences() throws Exception {
DelegatingMissingAuthorityAccessDeniedHandler accessDeniedHandler = this.builder.build();
accessDeniedHandler.setDefaultAccessDeniedHandler(this.defaultAccessDeniedHandler);
accessDeniedHandler.handle(this.request, this.response, new AccessDeniedException("access denied"));
verify(this.defaultAccessDeniedHandler).handle(any(), any(), any());
}
@Test
void whenMultipleAuthoritiesThenFirstMatchCommences() throws Exception {
AuthenticationEntryPoint passwordEntryPoint = mock(AuthenticationEntryPoint.class);
this.builder.addEntryPointFor(passwordEntryPoint, "PASSWORD");
AccessDeniedHandler accessDeniedHandler = this.builder.build();
accessDeniedHandler.handle(this.request, this.response, missingAuthorities("PASSWORD", "FACTOR"));
verify(passwordEntryPoint).commence(any(), any(), any());
accessDeniedHandler.handle(this.request, this.response, missingAuthorities("FACTOR", "PASSWORD"));
verify(this.factorEntryPoint).commence(any(), any(), any());
}
@Test
void whenCustomRequestCacheThenUses() throws Exception {
RequestCache requestCache = mock(RequestCache.class);
DelegatingMissingAuthorityAccessDeniedHandler accessDeniedHandler = this.builder.build();
accessDeniedHandler.setRequestCache(requestCache);
accessDeniedHandler.handle(this.request, this.response, missingAuthorities("FACTOR"));
verify(requestCache).saveRequest(any(), any());
verify(this.factorEntryPoint).commence(any(), any(), any());
}
@Test
void whenKnownAuthorityButNoRequestMatchThenCommences() throws Exception {
AuthenticationEntryPoint passwordEntryPoint = mock(AuthenticationEntryPoint.class);
RequestMatcher xhr = new RequestHeaderRequestMatcher("X-Requested-With");
this.builder.addEntryPointFor((ep) -> ep.addEntryPointFor(passwordEntryPoint, xhr), "PASSWORD");
AccessDeniedHandler accessDeniedHandler = this.builder.build();
accessDeniedHandler.handle(this.request, this.response, missingAuthorities("PASSWORD"));
verify(passwordEntryPoint).commence(any(), any(), any());
}
@Test
void whenMultipleEntryPointsThenFirstRequestMatchCommences() throws Exception {
AuthenticationEntryPoint basicPasswordEntryPoint = mock(AuthenticationEntryPoint.class);
AuthenticationEntryPoint formPasswordEntryPoint = mock(AuthenticationEntryPoint.class);
RequestMatcher xhr = new RequestHeaderRequestMatcher("X-Requested-With");
this.builder.addEntryPointFor(
(ep) -> ep.addEntryPointFor(basicPasswordEntryPoint, xhr).defaultEntryPoint(formPasswordEntryPoint),
"PASSWORD");
AccessDeniedHandler accessDeniedHandler = this.builder.build();
accessDeniedHandler.handle(this.request, this.response, missingAuthorities("PASSWORD"));
verify(formPasswordEntryPoint).commence(any(), any(), any());
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("X-Requested-With", "XmlHttpRequest");
accessDeniedHandler.handle(request, this.response, missingAuthorities("PASSWORD"));
verify(basicPasswordEntryPoint).commence(any(), any(), any());
}
AuthorizationDeniedException missingAuthorities(String... authorities) {
Collection<GrantedAuthority> granted = AuthorityUtils.createAuthorityList(authorities);
AuthorityAuthorizationDecision decision = new AuthorityAuthorizationDecision(false, granted);
return new AuthorizationDeniedException("access denied", decision);
}
}

View File

@ -26,10 +26,15 @@ import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.TestAuthentication;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.core.context.SecurityContextImpl;
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.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
@ -191,6 +196,83 @@ 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?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?factor=ott&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
public void generateWhenAuthenticatedThenReadOnlyUsername() throws Exception {
SecurityContextHolderStrategy strategy = mock(SecurityContextHolderStrategy.class);
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");
filter.setSecurityContextHolderStrategy(strategy);
given(strategy.getContext()).willReturn(new SecurityContextImpl(TestAuthentication.authenticatedUser()));
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(TestMockHttpServletRequests.get("/login").build(), response, this.chain);
assertThat(response.getContentAsString()).contains("Request a One-Time Token");
assertThat(response.getContentAsString()).contains(
"""
<input type="text" id="ott-username" name="username" value="user" placeholder="Username" required readonly>
""");
assertThat(response.getContentAsString()).contains("""
<input type="text" id="username" name="username" value="user" placeholder="Username" required readonly>
""");
}
@Test
void generatesThenRenders() throws ServletException, IOException {
DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter(