Spring React - Security!

Spring Security with WebFlux

그림 출처: Hands-On Spring Security 5 for Reactive Applications

1

그림을 보면 Spring Security 개발진들이 기존 WebMVC 와 동일하게 환경을 구성하기 위해 노력한 과정을 볼 수 있다.
Filter 기반의 인증과정, AuthenticationManagerUserDetailService 를 사용한 인증과정이 기존 WebMVC 와 유사한것을 확인할 수 있다.

spring-boot-starter-security 모듈 역시 WebFlux 를 지원할 수 있도록 업데이트 되었다.

SecurityContext from Reactor Context

WebfluxWebMVC 에서 Spring Security 의 가장 큰 차이점은 기존의 SecurityContext 의 개념이 사용되지 않는 것.

WebMVC Spring SecurityThreadLocalSecurityContext 를 저장해 한번의 연결동안 Authentication 인증객체를 계속 유지했지만,
Webflux 에선 하나의 연결에 여러개의 Thread 꼬여있을 수 있어 Reactor Context 를 사용한다.
(하나의 연결에대해 하나의 스레드가 담당하여 처리한다는 보장이 없다)

WebMVC 에선 SecurityContextHolder 에서 SecurityContext 를 가져왔다면
WebFlux 에선 ReactiveSecurityContextHolder 에서 SecurityContext 를 가져온다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1")
public class SecuredProfileController {

    private final ProfileService profileService;

    @GetMapping("/profiles")
    public Mono<Profile> getProfile() {
        return ReactiveSecurityContextHolder.getContext() // Mono<SecurityContext> 반환
            .map(SecurityContext::getAuthentication)
            .flatMap(auth -> profileService.getByUser(auth.getName()));
    }
}

로그인한 유저의 SecurityContext 가져와 Profile 에서 검색 후 출력한다.
당연히 SecurityContext::getAuthentication 메서드는 Reactor Context 를 사용한다.

// ReactiveSecurityContextHolder.java

public static Mono<SecurityContext> getContext() {
    return Mono.subscriberContext()
        .filter( c -> c.hasKey(SECURITY_CONTEXT_KEY))
        .flatMap( c-> c.<Mono<SecurityContext>>get(SECURITY_CONTEXT_KEY));
}

사실 메소드의 인자로 추가하면 스프링 시큐리티가 구독자 컨텍스트(subscriber context) 에서 Authentication 정보를 추출해서 인자로 주입해준다.

@GetMapping("/profiles/auth")
public Mono<Member> getProfileAuth(Authentication auth) {
    return memberRepo.findByUserName(auth.getName());
}

이런 코드가 작성 가능한 이유는 우리가 작성한 비즈니스 로직 전까지 SecurityContext 객체를 지속적으로 매개변수로 넘기기 때문.

SecurityWebFilterChain

2

인증과정을 거친 SecurityContext 가 해당 Reactor Context 에 존재해야하고,
Authentication 객체가 SecurityContext 안에 할당되어야 한다.

Spring MVC 방식에서 HttpSecurity 를 설정해서 각종 보안설정을 했듯이
Spring WebFlux 방식에서 SecurityWebFilterChain 을 설정해서 보안설정이 이루어진다.

아래 예는 Member 들을 사전에 입력해두고 formLogin 설정시 ReactiveUserDetailsService 를 통해 로그인 지원하는 설정이다.

@Configuration
@EnableReactiveMethodSecurity
public class SecurityConfig {
        
    @Bean
    public CommandLineRunner demo(MemberRepo repository, PasswordEncoder encoder) {
        return (args) -> {
            log.info("save start!");
            // save a few customers
            repository.saveAll(Arrays.asList(
                Member.builder().name("Kim").password(encoder.encode("Kim")).userName("Kim@test.com").role("ROLE_ADMIN").build(),
                Member.builder().name("Chloe").password(encoder.encode("Chloe")).userName("Chloe@test.com").role("ROLE_MEMBER").build(),
                Member.builder().name("David").password(encoder.encode("David")).userName("David@test.com").role("ROLE_MEMBER").build(),
                Member.builder().name("Michelle").password(encoder.encode("Michelle")).userName("Michelle@test.com").role("ROLE_MEMBER").build()
            )).blockLast(Duration.ofSeconds(10));
        };
    }

    @Bean
    public ReactiveUserDetailsService userDetailsService(MemberRepo repository) {
        return username -> repository.findByUserName(username)
            .map(member -> User.builder()
                .username(member.getUserName())
                .password(member.getPassword())
                .authorities(member.getRole())
                .build());
    }

    @Bean
    public SecurityWebFilterChain securityFilterChainConfigurer(ServerHttpSecurity httpSecurity) {
        return httpSecurity
                .authorizeExchange().pathMatchers("/member/**").hasAuthority("ROLE_MEMBER")
                .and()
                .httpBasic().and()
                .formLogin().and()
                .csrf().disable()
                .build();
    }
    ...
}

Spring MVC 에서 @EnableGlobalMethodSecurity 를 사용했듯이
Spring WebFlux 에서 @EnableReactiveMethodSecurity 를 사용해 Security 어노테이션들을 사용할 수 있다.

@PreAuthorize("hasRole('MEMBER')")
@GetMapping("/profiles/auth")
public Mono<Member> getProfile(Authentication auth) {
    return memberRepository.findByName(auth.getName());
}

ServerSecurityContextRepository

SecurityContextReactor Context 에 집어넣을 때 ServerSecurityContextRepository 를 사용한다.

package org.springframework.security.web.server.context;

public interface ServerSecurityContextRepository {
    Mono<Void> save(ServerWebExchange exchange, SecurityContext context);
    Mono<SecurityContext> load(ServerWebExchange exchange);
}

WebFlux with JWT

Custom SecurityContextRepository, AuthenticationManager

스프링 시큐리티는 SecurityContextRepository 를 통해 SecurityContext리액터 컨텍스트에 저장하고 삭제한다.

default 스프링 시큐리티의 경우 DB 로부터 UserDetails 를 가져와 등록해두고 사용하겠지만 우리는 JWT 를 사용하기에 ServerSecurityContextRepository 상속받아 커스터마이징 해야한다.

@Component
@RequiredArgsConstructor
public class SecurityContextRepository implements ServerSecurityContextRepository {

    private final AuthenticationManager authenticationManager;

    @Override
    public Mono<Void> save(ServerWebExchange swe, SecurityContext sc) {
        throw new UnsupportedOperationException("Not supported yet.");
    }

    @Override
    public Mono<SecurityContext> load(ServerWebExchange swe) {
        ServerHttpRequest request = swe.getRequest();
        String authToken = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
        if (authToken != null) {
            Authentication auth = new UsernamePasswordAuthenticationToken(authToken, authToken);
            return authenticationManager
                    .authenticate(auth)
                    .map(authentication -> new SecurityContextImpl(authentication));
        } else {
            return Mono.empty();
        }
    }
}

SecurityContext 를 생성 및 저장하기 request 헤더로 부터 JWT 토큰을 가져와 AuthenticationManager로 넘기는 코드가 load 에 정의되어 있다.

@Component
@RequiredArgsConstructor
public class AuthenticationManager implements ReactiveAuthenticationManager {

    private final JwtTokenUtil jwtUtil;

    @Override
    public Mono<Authentication> authenticate(Authentication authentication) {
        String authToken = authentication.getCredentials().toString();
        if (!jwtUtil.validateToken(authToken)) {
            return Mono.empty();
        }
        Claims claims = jwtUtil.getAllClaimsFromToken(authToken);
        String role = claims.get("role", String.class);
        List<GrantedAuthority> authorities = Collections.singletonList(new SimpleGrantedAuthority(role));
        return Mono.just(new UsernamePasswordAuthenticationToken(claims.getSubject(), null, authorities));
    }
}

AuthenticationManager 는 전달받은 토큰으로 role 을 꺼내어 Authority 를 지정하고 Authentication 인증 객체를 반환한다.

SecurityContextPath 가 리액터 컨텍스트로 변경되었을 뿐 기존 WebMVC 모델의 스프링 시큐리티 방식과 비슷하다.

// ReactorContextWebFilter 에 적용할 시큐리티 필터
// 기존 mvc 에서 사용하던 HttpSecurity 에서 webflux 용으로 정의된 ServerHttpSecurity
// 기존 spring security 사용 방식과 크게 다르지 않다.
@Bean 
public SecurityWebFilterChain securityFilterChainConfigurer(ServerHttpSecurity httpSecurity) {
    return httpSecurity
        .exceptionHandling()
        //.authenticationEntryPoint((swe, e) -> Mono.fromRunnable(() -> // 미 로그인
        //        swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED)))
        //.accessDeniedHandler((swe, e) -> Mono.fromRunnable(() -> // 미 로그인
        //        swe.getResponse().setStatusCode(HttpStatus.FORBIDDEN)))
        .authenticationEntryPoint((swd, e) -> Mono.error(new AuthenticationCredentialsNotFoundException("")))
        .accessDeniedHandler((swe, e) -> Mono.error(new AccessDeniedException("")))
        .and()
        .authenticationManager(authenticationManager)
        .securityContextRepository(securityContextRepository)
        .csrf().disable()
        .cors().disable()
        .httpBasic().disable() // Basic Authentication disable
        .formLogin().disable()
        .authorizeExchange()
        .pathMatchers("/member/join", "/member/login").permitAll()
        .pathMatchers("/member/**", "/rent/**").authenticated()
        .pathMatchers("/admin/**").hasAnyRole("ROLE_ADMIN")
        .anyExchange().permitAll()
        .and()
        .build();
}

마지막으로 ServerHttpSecurity 에 커스터마이징한 SecurityContextRepository, AuthenticationManager 를 등록하고 별도의 에러 핸들링 처리를 한다면 에러를 반환해 핸들링,
없다면 HttpStatus.UNAUTHORIZED 로 단순 HTTP Status 만 반환할 수 있다.