Search code examples
javaspring-bootspring-security

What difference does anyExchange().authenticated() really make?


What difference does anyExchange().authenticated() really make? Consider this MRE:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.2</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>security-mre</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>security-mre</name>
    <description>security-mre</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.projectreactor</groupId>
            <artifactId>reactor-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>
package com.example.securitymre.controller;

import com.example.securitymre.data.Hello;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {
    @GetMapping("/hello")
    public Hello getHello() {
        return new Hello();
    }
}
package com.example.securitymre.data;

import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor
@Getter
public class Hello {
    private final String message = "Hello!";
}

I hardly wrote any code, no security config, and yet

curl -i localhost:8080/hello
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Realm"
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0
Referrer-Policy: no-referrer
content-length: 0

I'm already barred from using my application unauthenticated

Experiment #2

package com.example.securitymre.data;

import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor
@Getter
public class Hello {
    private String message = "Hello!";

    public Hello(String message) {
        this.message = message;
    }
}
package com.example.securitymre.controller;

import com.example.securitymre.data.Hello;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {
    @GetMapping("/hello")
    public Hello getHello() {
        return new Hello();
    }
    @GetMapping("/secured-hello")
    public Hello getSecuredHello() {
        return new Hello("Secured hello!");
    }
}
package com.example.securitymre.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
    @Bean
    public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity security) {
        return security
                .authorizeExchange(authorizeExchangeSpec -> authorizeExchangeSpec
                        .pathMatchers("/hello").permitAll()
                        .anyExchange()/* by "any" we mean GET /secured-hello */.authenticated()
                )
                .build();
    }
}
curl -i localhost:8080/hello                                                         
HTTP/1.1 200 OK                                                                                     
Content-Type: application/json                                                                      
Content-Length: 20                                                                                  
Cache-Control: no-cache, no-store, max-age=0, must-revalidate                                       
Pragma: no-cache                                                                                    
Expires: 0                                                                                          
X-Content-Type-Options: nosniff                                                                     
X-Frame-Options: DENY                                                                               
X-XSS-Protection: 0                                                                                 
Referrer-Policy: no-referrer                                                                                                                                                                            
{"message":"Hello!"}
                                                                                
curl -i localhost:8080/secured-hello                                                 
HTTP/1.1 401 Unauthorized                                                                           
WWW-Authenticate: Basic realm="Realm"                                                               
Cache-Control: no-cache, no-store, max-age=0, must-revalidate                                       
Pragma: no-cache                                                                                    
Expires: 0                                                                                          
X-Content-Type-Options: nosniff                                                                     
X-Frame-Options: DENY                                                                               
X-XSS-Protection: 0                                                                                 
Referrer-Policy: no-referrer                                                                        
content-length: 0  

Or without anyExchange().authenticated():

package com.example.securitymre.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
    @Bean
    public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity security) {
        return security
                .authorizeExchange(authorizeExchangeSpec -> authorizeExchangeSpec
                        .pathMatchers("/hello").permitAll()
                )
                .build();
    }
}
curl -i localhost:8080/hello                                                         
HTTP/1.1 200 OK                                                                                     
Content-Type: application/json                                                                      
Content-Length: 20                                                                                  
Cache-Control: no-cache, no-store, max-age=0, must-revalidate                                       
Pragma: no-cache                                                                                    
Expires: 0                                                                                          
X-Content-Type-Options: nosniff                                                                     
X-Frame-Options: DENY                                                                               
X-XSS-Protection: 0                                                                                 
Referrer-Policy: no-referrer                                                                                                                                                                            
{"message":"Hello!"}  
                                                                              
curl -i localhost:8080/secured-hello                                                 
HTTP/1.1 401 Unauthorized                                                                           
WWW-Authenticate: Basic realm="Realm"                                                               
Cache-Control: no-cache, no-store, max-age=0, must-revalidate                                       
Pragma: no-cache                                                                                    
Expires: 0                                                                                          
X-Content-Type-Options: nosniff                                                                     
X-Frame-Options: DENY                                                                               
X-XSS-Protection: 0                                                                                 
Referrer-Policy: no-referrer                                                                        
content-length: 0  

Literally, zero difference

I don't claim to have a lot of experience, but in each project I worked on, had that anyRequest().authenticated() line. Now it appears it's some default, and it doesn't have any practical purpose

Am I missing something? What does that line do, exactly?


Solution

  • TLDR: It doesn't do anything since it's called for you anyway

    ServerHttpSecurity is basically a container for configuration properties that are then used to build a WebFilter (or more specifically, to create a SecurityWebFilterChain which is then wrapped in a WebFilterChainProxy object which is a WebFilter subtype). ServerHttpSecurity has a bunch of nested classes that encapsulate certain aspects of that configuration. One of them is AuthorizeExchangeSpec whose purpose in life is to supply an AuthorizationManager to AuthorizationFilter's constructor, call it, and add the filter instance to ServerHttpSecurity (the outer class) as an authorization filter. Here's the code that does it:

    // ServerHttpSecurity.AuthorizeExchangeSpec
    
            protected void configure(ServerHttpSecurity http) {
                Assert.state(this.matcher == null,
                        () -> "The matcher " + this.matcher + " does not have an access rule defined");
                ReactiveAuthorizationManager<ServerWebExchange> manager = this.managerBldr.build();
                ObservationRegistry registry = getBeanOrDefault(ObservationRegistry.class, ObservationRegistry.NOOP);
                if (!registry.isNoop()) {
                    manager = new ObservationReactiveAuthorizationManager<>(registry, manager);
                }
                AuthorizationWebFilter result = new AuthorizationWebFilter(manager);
                http.addFilterAt(result, SecurityWebFiltersOrder.AUTHORIZATION);
            }
    

    That manager is treated a bit like a map. When you call all those pathMatchers().hasAnyRole(), anyExchange().authenticated(), etc., you're basically putting key-value pairs into that AuthenticationManager (or rather, its builder). The "keys" are ServerWebExchangeMatchers which are nothing but glorified Predicate<ServerWebExchange> (in effect). That anyExchange() call is for all intents and purposes equivalent to serverWebExchange -> true

    // ServerWebExchangeMatchers
    
    /*
     maybe I'm stupid, but aren't this anonymous subclass's equals() and hashcode() 
    exactly the same as if it was a lambda, i.e. this == o?
    */
    
        @SuppressWarnings("Convert2Lambda")
        public static ServerWebExchangeMatcher anyExchange() {
            // we don't use a lambda to ensure a unique equals and hashcode
            // which otherwise can cause problems with adding multiple entries to an ordered
            // LinkedHashMap
            return new ServerWebExchangeMatcher() {
    
                @Override
                public Mono<MatchResult> matches(ServerWebExchange exchange) {
                    return ServerWebExchangeMatcher.MatchResult.match();
                }
    
            };
        }
    

    The "values" are ReactiveAuthorizationManagers (yes, the big authorization manager that we construct is sort of made up of tiny authorization managers inside) which are effectively Predicate<Authentication>

    public class AuthenticatedReactiveAuthorizationManager<T> implements ReactiveAuthorizationManager<T> {
    
        private AuthenticationTrustResolver authTrustResolver = new AuthenticationTrustResolverImpl();
    
        AuthenticatedReactiveAuthorizationManager() {
        }
    
        @Override
        public Mono<AuthorizationDecision> check(Mono<Authentication> authentication, T object) {
            return authentication.filter(this::isNotAnonymous)
                .map(this::getAuthorizationDecision)
                .defaultIfEmpty(new AuthorizationDecision(false));
        }
    
        private AuthorizationDecision getAuthorizationDecision(Authentication authentication) {
            return new AuthorizationDecision(authentication.isAuthenticated());
        }
    
    public class AuthorizationDecision {
    
        private final boolean granted;
    
        public AuthorizationDecision(boolean granted) {
            this.granted = granted;
        }
    
        public boolean isGranted() {
            return this.granted;
        }
    

    So when anyExchange().authenticated() is invoked, the following method is invoked with AuthenticatedReactiveAuthorizationManager.authenticated() as its argument. Basically, it adds a key-value pair that says, "Whatever the request, greenlight it only if its authentication isAuthenticated()". By the way, I could trick that authorization filter by simply registering another filter bean that simply toggles that flag

    // ServerHttpSecurity.AuthorizeExchangeSpec
    
        public AuthorizeExchangeSpec authenticated() {
            return access(AuthenticatedReactiveAuthorizationManager.authenticated());
        }
    
    // ServerHttpSecurity.AuthorizeExchangeSpec
    
                public AuthorizeExchangeSpec access(ReactiveAuthorizationManager<AuthorizationContext> manager) {
                    AuthorizeExchangeSpec.this.managerBldr
                        .add(new ServerWebExchangeMatcherEntry<>(AuthorizeExchangeSpec.this.matcher, manager));
                    AuthorizeExchangeSpec.this.matcher = null;
                    return AuthorizeExchangeSpec.this;
                }
    

    Now, why is the behavior the same even without any explicit configuration at all?

    First, there's an out-of-the-box configuration called WebFluxSecurityConfiguration that supplies a WebFliterChainProxy built from a default ServerHttpSecurity. Here it is. Notice it doesn't call authorizeExchange() which means authorizeExchange stays null (I checked it with the debugger)

    // ServerHttpSecurityConfiguration
    
    /* 
    By the way, do you know that this bean's name is "org.springframework.security.config.annotation.web.reactive.HttpSecurityConfiguration.httpSecurity"
    even though it's declared in a **Server**HttpSecurityConfiguration? I guess it's due to a copy-paste from the old HttpSecurity
    */
    
        @Bean(HTTPSECURITY_BEAN_NAME)
        @Scope("prototype")
        ServerHttpSecurity httpSecurity() {
            ContextAwareServerHttpSecurity http = new ContextAwareServerHttpSecurity();
            // @formatter:off
            return http.authenticationManager(authenticationManager())
                .headers().and()
                .logout().and();
            // @formatter:on
        }
    
    // ServerHttpSecurity
    
        @Deprecated(since = "6.1", forRemoval = true)
        public AuthorizeExchangeSpec authorizeExchange() {
            if (this.authorizeExchange == null) {
                this.authorizeExchange = new AuthorizeExchangeSpec();
            }
            return this.authorizeExchange;
        }
    
    // ServerHttpSecurity
    
        public ServerHttpSecurity authorizeExchange(Customizer<AuthorizeExchangeSpec> authorizeExchangeCustomizer) {
            if (this.authorizeExchange == null) {
                this.authorizeExchange = new AuthorizeExchangeSpec();
            }
            authorizeExchangeCustomizer.customize(this.authorizeExchange);
            return this;
        }
    

    Second, when they do retrieve that bean from the context, they keep configuring it which includes calling those exact methods, anyRequest().authenticated()

    The fact that they retrieve a bean from the context (which should be completely initialized by that point) and still keep configuring it, spreading defaults all over the codebase instead of keeping them in one place makes me unhappy

        @Bean(SPRING_SECURITY_WEBFILTERCHAINFILTER_BEAN_NAME)
        @Order(WEB_FILTER_CHAIN_FILTER_ORDER)
        WebFilterChainProxy springSecurityWebFilterChainFilter() {
            WebFilterChainProxy proxy = new WebFilterChainProxy(getSecurityWebFilterChains());
            if (!this.observationRegistry.isNoop()) {
                proxy.setFilterChainDecorator(new ObservationWebFilterChainDecorator(this.observationRegistry));
            }
            return proxy;
        }
    
        @Bean(name = AbstractView.REQUEST_DATA_VALUE_PROCESSOR_BEAN_NAME)
        CsrfRequestDataValueProcessor requestDataValueProcessor() {
            return new CsrfRequestDataValueProcessor();
        }
    
        @Bean
        static BeanFactoryPostProcessor conversionServicePostProcessor() {
            return new RsaKeyConversionServicePostProcessor();
        }
    
        private List<SecurityWebFilterChain> getSecurityWebFilterChains() {
            List<SecurityWebFilterChain> result = this.securityWebFilterChains;
            if (ObjectUtils.isEmpty(result)) {
                return Arrays.asList(springSecurityFilterChain());
            }
            return result;
        }
    
        private SecurityWebFilterChain springSecurityFilterChain() {
            ServerHttpSecurity http = this.context.getBean(ServerHttpSecurity.class); // get default ServerHttpSecurity bean...
            return springSecurityFilterChain(http);
        }
    
        /**
         * The default {@link ServerHttpSecurity} configuration.
         * @param http
         * @return
         */
        private SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
            http.authorizeExchange().anyExchange().authenticated(); // ...and keep configuring it! Wtf is this?
            if (isOAuth2Present && OAuth2ClasspathGuard.shouldConfigure(this.context)) {
                OAuth2ClasspathGuard.configure(this.context, http);
            }
            else {
                http.httpBasic();
                http.formLogin();
            }
            SecurityWebFilterChain result = http.build();
            return result;
        }
    

    I could mention WebHttpHandlerBuilder (which asked the context for all WebFilter beans, including our WebFilterChainProxy), ConfigurationClassPostProcessor (which put the bean definition of that default ServerHttpSecurity to the context's bean factory in the first place), but I guess that would be too much of a diversion