Search code examples
javaspringspring-bootspring-security

How am I supposed to access my endpoints in a no-config WebFlux Security application?


Suppose, this is my app. It has no explicit config and only one controller with one endpoint. Very simple

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 String message = "Hello!";

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

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SecurityMreApplication {

    public static void main(String[] args) {
        SpringApplication.run(SecurityMreApplication.class, args);
    }

}

When I try to hit GET /hello, I receive a 401 error which is weird since I haven't configured any security. However, I have Spring Security in my classpath

<?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>

Suppose, due to that dependency, there's some autoconfiguration that requires each request to be authenticated. But what are my requests authenticated against? I haven't configured any UserDetailsService that queries a database or something

Is there any way I can interact with a Spring Boot Security application with no explicit configuration and actually visit my endpoints? Or is it in practical terms inaccessible unless I explicitly write some configuration?


Solution

  • Note. This answer is intended not only to answer the question but also to give a better understanding of Spring Security's internals. If you're only interested in the answer itself, skip to the bold part

    Yes, there are a few ways you can access your endpoint

    First, you need to understand what Spring Security is. Essentially, it's an extension to Spring Web that makes it easier to add security filters to your application. All those fluent methods of (Server)HttpSecurity are in the end simply mapped to a bunch of filters that are, by default, applied on each request. When it comes to authentication, the central role is played by Spring's AuthenticationWebFilter. Here's what it does

    1. It extracts an Authentication from the exchange with the help of an AuthenticationConverter. Its basic implementation, ServerHttpBasicAuthenticationConverter, takes the request's Authorization header, which it expects to be in the format of basic <Base64-encoded string>, discards the "basic" prefix, decodes the encoded claims, and splits the resulting string by a colon (:). What's on the left side of it is assumed to be a username, what's on the right side of it is assumed to be a password
    // AuthenticationWebFilter's converter is initialized to ServerHttpBasicAuthenticationConverter in-line. You can change it by calling a setter, but it never happens when there's no explicit config
    
    public class AuthenticationWebFilter implements WebFilter {
    
        private static final Log logger = LogFactory.getLog(AuthenticationWebFilter.class);
    
        private final ReactiveAuthenticationManagerResolver<ServerWebExchange> authenticationManagerResolver;
    
        private ServerAuthenticationSuccessHandler authenticationSuccessHandler = new WebFilterChainServerAuthenticationSuccessHandler();
    
        private ServerAuthenticationConverter authenticationConverter = new ServerHttpBasicAuthenticationConverter();
    
    // ServerHttpBasicAuthenticationConverter
    
        public Mono<Authentication> apply(ServerWebExchange exchange) {
            ServerHttpRequest request = exchange.getRequest();
            String authorization = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
            if (!StringUtils.startsWithIgnoreCase(authorization, "basic ")) {
                return Mono.empty();
            }
            String credentials = (authorization.length() <= BASIC.length()) ? "" : authorization.substring(BASIC.length());
            String decoded = new String(base64Decode(credentials), this.credentialsCharset);
            String[] parts = decoded.split(":", 2);
            if (parts.length != 2) {
                return Mono.empty();
            }
            return Mono.just(UsernamePasswordAuthenticationToken.unauthenticated(parts[0], parts[1]));
        }
    
        private byte[] base64Decode(String value) {
            try {
                return Base64.getDecoder().decode(value);
            }
            catch (Exception ex) {
                return new byte[0];
            }
        }
    
    1. Then it delegates the actual authentication to its AuthenticationManager. AuthenticationManager is an interface with one method, authenticate(), which takes an Authentication and returns an Authentication. It has to, at the very least, check the credentials (a password, typically) and then, if they are valid, return an authenticated Authentication (one that returns true on isAuthenticated() calls). That Authentication is then put in a SecurityContext thus making the request itself authenticated
    // AuthenticationWebFilter
    
        @Override
        public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
            return this.requiresAuthenticationMatcher.matches(exchange)
                .filter((matchResult) -> matchResult.isMatch())
                // the filter tells its AuthenticationConverter to extract a token from the exchange...
                .flatMap((matchResult) -> this.authenticationConverter.convert(exchange))
                .switchIfEmpty(chain.filter(exchange).then(Mono.empty()))
                // ...and then tells its AuthenticationConverter to check the claims
                .flatMap((token) -> authenticate(exchange, chain, token))
                .onErrorResume(AuthenticationException.class, (ex) -> this.authenticationFailureHandler
                    .onAuthenticationFailure(new WebFilterExchange(exchange, chain), ex));
        }
    
        private Mono<Void> authenticate(ServerWebExchange exchange, WebFilterChain chain, Authentication token) {
            return this.authenticationManagerResolver.resolve(exchange)
                // here's the key part
                .flatMap((authenticationManager) -> authenticationManager.authenticate(token))
                .switchIfEmpty(Mono
                    .defer(() -> Mono.error(new IllegalStateException("No provider found for " + token.getClass()))))
                .flatMap(
                        (authentication) -> onAuthenticationSuccess(authentication, new WebFilterExchange(exchange, chain)))
                .doOnError(AuthenticationException.class,
                        (ex) -> logger.debug(LogMessage.format("Authentication failed: %s", ex.getMessage())));
        }
    

    You didn't explicitly register an AuthenticationManager, but Spring Security created one for you. Here it is. It's from ServerHttpSecurityConfiguration (which also autoconfigured your overall ServerHttpSecurity)

        private ReactiveAuthenticationManager authenticationManager() {
            if (this.authenticationManager != null) {
                return this.authenticationManager;
            }
            if (this.reactiveUserDetailsService != null) {
                UserDetailsRepositoryReactiveAuthenticationManager manager = new UserDetailsRepositoryReactiveAuthenticationManager(
                        this.reactiveUserDetailsService);
                if (this.passwordEncoder != null) {
                    manager.setPasswordEncoder(this.passwordEncoder);
                }
                manager.setUserDetailsPasswordService(this.userDetailsPasswordService);
                if (!this.observationRegistry.isNoop()) {
                    return new ObservationReactiveAuthenticationManager(this.observationRegistry, manager);
                }
                return manager;
            }
            return null;
        }
    

    As shown in the snippet above, it creates an AuthenticationManager subtype called UserDetailsRepositoryReactiveAuthenticationManager from an injected ReactiveUserDetailsService instance. UserDetailsService (reactive or not) is a simple interface that retrieves a user (or rather, a UserDetails instance) by username. UserDetailsRepositoryReactiveAuthenticationManager uses it to retrieve matching UserDetails and then compares the presented password with the one of the UserDetails object. For example, if my claims are "john_dow:12345", the AuthenticationManager will call userDetailsService.findByUsername("john_dow"), then the UserDetailsService will, if it can, return some John Dow (a UserDetails with username john_dow), and finally the AuthenticationManager will compare my "12345" with johnDoeUserDetails.getPassword(). It will do it, again, indirectly by calling a boolean method passwordEncoder.matches("12345", johnDoeUserDetails.getPassword()). In a simple scenario, such as the one we have, the PasswordEncoder would do no encoding or decoding at all and basically just call equals() between the two arguments. In a more realistic scenario, the second argument would be an encoded password, e.g. using the Bcrypt algorithm

    Anyway, once the claims are verified, the AuthenticationManager would create an authenticated Authentication like so. UsernamePasswordAuthenticationToken is a subtype of Authentication. Notice it populates the authorities (roles) too

    // AbstractUserDetailsReactiveAuthenticationManager
    
        private UsernamePasswordAuthenticationToken createUsernamePasswordAuthenticationToken(UserDetails userDetails) {
            return UsernamePasswordAuthenticationToken.authenticated(userDetails, userDetails.getPassword(),
                    userDetails.getAuthorities());
        }
    

    Now, you may be wondering, "Where did it find that UserDetailsService to inject if I didn't write it too?" Spring Security, again, created one for you. It's declared in another configuration class, namely ReactiveUserDetailsServiceAutoConfiguration. By the way, notice the @ConditionalOnClass and @ConditionalOnMissingBean annotations. This is the origin of all that. You had a ReactiveAuthenticationManager class in your classpath (since you had a Spring Security dependency), but you didn't declare any of the listed beans explicitly, that's why this whole autoconfiguration kicked in in the first place!

    @AutoConfiguration(before = ReactiveSecurityAutoConfiguration.class, after = RSocketMessagingAutoConfiguration.class)
    @ConditionalOnClass({ ReactiveAuthenticationManager.class })
    @ConditionalOnMissingBean(
            value = { ReactiveAuthenticationManager.class, ReactiveUserDetailsService.class,
                    ReactiveAuthenticationManagerResolver.class },
            type = { "org.springframework.security.oauth2.jwt.ReactiveJwtDecoder" })
    @Conditional({ ReactiveUserDetailsServiceAutoConfiguration.RSocketEnabledOrReactiveWebApplication.class,
            ReactiveUserDetailsServiceAutoConfiguration.MissingAlternativeOrUserPropertiesConfigured.class })
    @EnableConfigurationProperties(SecurityProperties.class)
    public class ReactiveUserDetailsServiceAutoConfiguration {
    
        private static final String NOOP_PASSWORD_PREFIX = "{noop}";
    
        private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$");
    
        private static final Log logger = LogFactory.getLog(ReactiveUserDetailsServiceAutoConfiguration.class);
    
        @Bean
        public MapReactiveUserDetailsService reactiveUserDetailsService(SecurityProperties properties,
                ObjectProvider<PasswordEncoder> passwordEncoder) {
            SecurityProperties.User user = properties.getUser();
            UserDetails userDetails = getUserDetails(user, getOrDeducePassword(user, passwordEncoder.getIfAvailable()));
            return new MapReactiveUserDetailsService(userDetails);
        }
    
        private UserDetails getUserDetails(SecurityProperties.User user, String password) {
            List<String> roles = user.getRoles();
            return User.withUsername(user.getName()).password(password).roles(StringUtils.toStringArray(roles)).build();
        }
    
        private String getOrDeducePassword(SecurityProperties.User user, PasswordEncoder encoder) {
            String password = user.getPassword();
            if (user.isPasswordGenerated()) {
                logger.info(String.format("%n%nUsing generated security password: %s%n", user.getPassword()));
            }
            if (encoder != null || PASSWORD_ALGORITHM_PATTERN.matcher(password).matches()) {
                return password;
            }
            return NOOP_PASSWORD_PREFIX + password;
        }
    

    Notice what the code above does. It injects a SecurityProperties which has some nested user, then uses it as a template to create a brand new User which is a subtype of UserDetails. Let's check out the SecurityProperties class

    /**
     * Configuration properties for Spring Security.
     *
     * @author Dave Syer
     * @author Andy Wilkinson
     * @author Madhura Bhave
     * @since 1.0.0
     */
    @ConfigurationProperties(prefix = "spring.security")
    public class SecurityProperties {
    
        /**
         * Order applied to the {@code SecurityFilterChain} that is used to configure basic
         * authentication for application endpoints. Create your own
         * {@code SecurityFilterChain} if you want to add your own authentication for all or
         * some of those endpoints.
         */
        public static final int BASIC_AUTH_ORDER = Ordered.LOWEST_PRECEDENCE - 5;
    
        /**
         * Order applied to the {@code WebSecurityCustomizer} that ignores standard static
         * resource paths.
         */
        public static final int IGNORED_ORDER = Ordered.HIGHEST_PRECEDENCE;
    
        /**
         * Default order of Spring Security's Filter in the servlet container (i.e. amongst
         * other filters registered with the container). There is no connection between this
         * and the {@code @Order} on a {@code SecurityFilterChain}.
         */
        public static final int DEFAULT_FILTER_ORDER = OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER - 100;
    
        private final Filter filter = new Filter();
    
        private final User user = new User();
    
        public User getUser() {
            return this.user;
        }
    
        public Filter getFilter() {
            return this.filter;
        }
    
        public static class Filter {
    
            /**
             * Security filter chain order for Servlet-based web applications.
             */
            private int order = DEFAULT_FILTER_ORDER;
    
            /**
             * Security filter chain dispatcher types for Servlet-based web applications.
             */
            private Set<DispatcherType> dispatcherTypes = EnumSet.allOf(DispatcherType.class);
    
            public int getOrder() {
                return this.order;
            }
    
            public void setOrder(int order) {
                this.order = order;
            }
    
            public Set<DispatcherType> getDispatcherTypes() {
                return this.dispatcherTypes;
            }
    
            public void setDispatcherTypes(Set<DispatcherType> dispatcherTypes) {
                this.dispatcherTypes = dispatcherTypes;
            }
    
        }
    
        public static class User {
    
            /**
             * Default user name.
             */
            private String name = "user";
    
            /**
             * Password for the default user name.
             */
            private String password = UUID.randomUUID().toString();
    
            /**
             * Granted roles for the default user name.
             */
            private List<String> roles = new ArrayList<>();
    
            private boolean passwordGenerated = true;
    
            public String getName() {
                return this.name;
            }
    
            public void setName(String name) {
                this.name = name;
            }
    
            public String getPassword() {
                return this.password;
            }
    
            public void setPassword(String password) {
                if (!StringUtils.hasLength(password)) {
                    return;
                }
                this.passwordGenerated = false;
                this.password = password;
            }
    
            public List<String> getRoles() {
                return this.roles;
            }
    
            public void setRoles(List<String> roles) {
                this.roles = new ArrayList<>(roles);
            }
    
            public boolean isPasswordGenerated() {
                return this.passwordGenerated;
            }
    
        }
    
    }
    

    So what we see is that the default user has a username "user" and a password which is a randomly generated UUID. Unless it's reset (by calling setPassword()), it keeps that value and also keeps a passwordGenerated flag which is accessible via isPasswordGenerated(). Let's get back to ReactiveUserDetailsServiceAutoConfiguration

            if (user.isPasswordGenerated()) {
                logger.info(String.format("%n%nUsing generated security password: %s%n", user.getPassword()));
            }
    

    It appears, if we keep our generated password, we should be able to see it in our cosole output! Let's examine it

    INFO 9080 --- [           main] ctiveUserDetailsServiceAutoConfiguration : 
    
    Using generated security password: 82963d45-55ed-40ff-bc6c-bddef52c535a
    

    Booyah! Now, we got all the information we need

    1. We have a username
    2. We have a password
    3. We know the scheme (basic) and the algorithm (Base64)

    All we have to do is to generate a request with an Authorization header that has this value: basic <base64endcoded(user:<our random UUID>)>. There are free online Base64 tools that you can use. I liked this one. Note that you'll have a different UUID each time so you should check your console instead of pasting my UUID

    enter image description here

    enter image description here

    (The screenshot is from IntelliJ's HTTP client. You may use Postman or the good-old curls. Any client that lets you specify a header)

    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
    set-cookie: SESSION=; Max-Age=0; Expires=Sat, 17 Feb 2024 10:16:25 GMT; Path=/; HTTPOnly; SameSite=Lax
    
    {
      "message": "Hello!"
    }
    

    You may have noticed that SecurityProperties is @ConfigurationProperties. It means you can easily override those default values in your properties file. You won't see that log message anymore since you're going to have you own, non-generated password

    # application.yml
    
    spring:
      security:
        user:
          name: mickey_m
          password: pass
    

    enter image description here

    enter image description here

    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!"
    }
    

    You can also make a GET request to /login, copy a CSRF token from the HTML, make a POST request with username, password, and the CSRF token in the request body to the same path

    POST http://localhost:8080/login
    Content-Type: application/x-www-form-urlencoded
    
    username=mickey_m&password=pass&_csrf=MiyRbaso7IVlFnV8YstELfLORnHsvsg0JPW-qoGSqkri3WD7ChiiXZxO2rxILxFIW-ZwSJaoaxDVhv0ZRsLaz7WnnijX6VTP
    

    receive a 404 (since you don't have a mapping for / to which a request is redirected by default), then copy the session id from the response's cookie, and hit /hello with that key-value pair in your Cookie header. You'll get the same response

    GET http://localhost:8080/hello
    Cookie: SESSION=48d946ab-b9b9-4d93-99c5-eb484a21dbbe
    

    But I won't describe that method in detail since the answer is already quite long (besides, it's rather twisted)