Search code examples
spring-bootspring-securityspring-webflux

How do I implement token based authentication on a spring webflux api?


I've tried working with both ReactiveAuthenticationManager and a WebFilter. In both cases, the execution flow is some form of this:

@Component
@RequiredArgsConstructor
public class JwtReactiveAuthenticationManager implements ReactiveAuthenticationManager {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserRepository userRepository;

    @Override
    public Mono<Authentication> authenticate(Authentication authentication) {
        String token = authentication.getCredentials().toString();

        if (!jwtTokenProvider.validateToken(token)) {
            System.out.println("Invalid token");
            return Mono.empty();
        }
        String username = jwtTokenProvider.getUsernameFromToken(token);
        return userRepository.findByEmail(username)
            .switchIfEmpty(Mono.error(new RuntimeException("User not found")))
            .map(user -> {
                System.out.println("hydrated user from token. File is JRAM");
                System.out.println(user); // this prints user
                UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                        user.getUsername(),
                        null,
                        Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))
                );

                return authenticationToken;
            });
    }
}

As commented above, token is decoded and user is hydrated correctly. I believe the problem stems from binding it to the ReactiveSecurityContextHolder. I tried different variations of the below:

SecurityContextImpl securityContext = new SecurityContextImpl(authentication);
                        Context context = ReactiveSecurityContextHolder.withSecurityContext(Mono.just(securityContext));

In one instance, I tried setting security context and returning that to the mono. In another instance, I tried just setting the authentication directly on ReactiveSecurityContextHolder.withAuthentication. No dice. When I was working with ReactiveAuthenticationManager, I also tried ServerSecurityContextRepository

@Override
    public Mono<SecurityContext> load(ServerWebExchange swe) {
        String authHeader = swe.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String authToken = authHeader.substring(7);
            Authentication auth = new UsernamePasswordAuthenticationToken(authToken, authToken);
            return authenticationManager.authenticate(auth).map(SecurityContextImpl::new);
        } else {
            return Mono.empty();
        }
    }

Didn't help. Trying to read authentication from the ReactiveSecurityContextHolder in a subsequent mono method fails with a NullPointerException. So I'm guessing the setting breaks but I can't debug that cuz switchIfEmpty and onErrorResume just print out whatever they're given all the same.

Most stack overflow answers on the subject are outdated, using deprecated methods. Also none of these work for me while retrieving the user. Authentication is probably failing but at some point, I could access user parameter although it was blank. Other times, request just backs off with 401

Below is the securityConfig

http
                .csrf(ServerHttpSecurity.CsrfSpec::disable)
                .cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(request -> {
                    CorsConfiguration config = new CorsConfiguration();
                    // bunch of stuff
                    return config;
                }))
                /*.authenticationManager(authenticationManager)
                .securityContextRepository(securityContextRepository)*/
                .authorizeExchange(authorizeExchangeSpec -> {
                    authorizeExchangeSpec.pathMatchers(
                            "api/v1/user/login",
                            "api/v1/user/register","webjars/**",
                            "/v3/api-docs/**", "/swagger-ui.html",
                            "/swagger-ui/**").permitAll();
                    authorizeExchangeSpec.anyExchange().authenticated();
                })
                .addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)
                .build();

Kindly help spell out the necessary steps to pass my user to the security context and retrieve it from controller methods. I'm on spring boot 3.3.4. Thanks


Solution

  • With a simplistic version of your JwtReactiveAuthenticationManager and the below code, I'm able to access Authentication inside a controller.

    I hope I understood the issue correct.

    SecurityConfig

    import no.mycompany.webflux.JwtReactiveAuthenticationManager;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
    import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
    import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
    import org.springframework.security.config.web.server.ServerHttpSecurity;
    import org.springframework.security.web.server.SecurityWebFilterChain;
    import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
    import reactor.core.publisher.Mono;
    
    @Configuration
    @EnableWebFluxSecurity
    public class SecurityConfig {
    
        private final JwtReactiveAuthenticationManager authenticationManager;
    
        public SecurityConfig(JwtReactiveAuthenticationManager authenticationManager) {
            this.authenticationManager = authenticationManager;
        }
    
        @Bean
        public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
            return http
                    .csrf(ServerHttpSecurity.CsrfSpec::disable)
                    .authorizeExchange(exchanges -> exchanges
                            .anyExchange().hasRole("USER")
                    )
                    .addFilterAt(jwtAuthenticationFilter(), SecurityWebFiltersOrder.AUTHENTICATION)
                    .build();
        }
    
        private AuthenticationWebFilter jwtAuthenticationFilter() {
            var authenticationWebFilter = new AuthenticationWebFilter(authenticationManager);
    
            authenticationWebFilter.setServerAuthenticationConverter(exchange -> {
                String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization");
                if (authHeader != null && authHeader.startsWith("Bearer ")) {
                    var token = authHeader.substring(7);
                    // instance is passed to JwtReactiveAuthenticationManager#authenticate
                    return Mono.just(new UsernamePasswordAuthenticationToken(token, token));
                }
                return Mono.empty();
            });
    
            return authenticationWebFilter;
        }
    }
    

    Controller

    import org.springframework.security.core.Authentication;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    import reactor.core.publisher.Mono;
    
    @RestController
    @RequestMapping("/api")
    public class SomeController {
    
        @GetMapping
        public Mono<String> sayHello(Authentication authentication) {
            return Mono.just("Hello World " + authentication.getName());
        }
    }
    

    Update Added implementation of my mock JwtReactiveAuthenticationManager

    import org.springframework.security.authentication.ReactiveAuthenticationManager;
    import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.stereotype.Component;
    import reactor.core.publisher.Mono;
    
    import java.util.Collections;
    
    @Component
    public class JwtReactiveAuthenticationManager implements ReactiveAuthenticationManager {
    
        @Override
        public Mono<Authentication> authenticate(Authentication authentication) {
            // to get token: authentication.getCredentials().toString();
            // for this demo, we return a fixed user
            return Mono.just(new UsernamePasswordAuthenticationToken(
                    "user1",
                    null,
                    Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")))
            );
        }
    }
    

    build.gradle.kts

    plugins {
        java
        id("org.springframework.boot") version "3.4.1"
        id("io.spring.dependency-management") version "1.1.7"
    }
    
    group = "no.mycompany"
    version = "0.0.1-SNAPSHOT"
    
    java {
        toolchain {
            languageVersion = JavaLanguageVersion.of(21)
        }
    }
    
    repositories {
        mavenCentral()
    }
    
    dependencies {
        implementation("org.springframework.boot:spring-boot-starter-security")
        implementation("org.springframework.boot:spring-boot-starter-webflux")
        testImplementation("org.springframework.boot:spring-boot-starter-test")
    }
    
    tasks.withType<Test> {
        useJUnitPlatform()
    }
    

    Postman