Small Asteroid Blog

[Spring] Spring WebFlux에서의 인증 및 인가 처리 본문

백엔드/Spring

[Spring] Spring WebFlux에서의 인증 및 인가 처리

작은소행성☄️ 2024. 11. 27. 22:22
728x90

WebFlux와 MVC 의 인증 인가 방식

WebFlux 환경에서 API 호출 후, Security Context가 null로 설정되는 현상을 발견했다.

이를 통해 Reactive 환경에서의 인증 및 인가 방식이 MVC와 다르다는 점을 알게 되었다. 

 

 

Reactive 환경에서의 인증과 인가

Spring Security 의 Reactive 모듈을 사용할 경우, 기존 MVC 환경과 달리 Mono, Flux 기반의 비동기 방식으로 동작한다. 

WebFlux에서는 비동기적으로 Security Context를 관리하고 사용자의 인증 및 권한을 수행해야한다. 

 

 

Security Context 가 null 로 설정되는 이유 

Reactive 환경에서는 요청 처리가 비동기적으로 진행되기 때문에,  

Security Context 에 값이 적절히 초기화 되지 않으면 null 로 설정될 수 있다. 

이는 Security Context 가 동기 방식으로 관리되는 MVC 환경과의 주요 차이점이다. 

 

 

WebFlux 환경에서 JWT 기반 인증/인가 구현

1. 의존성 추가

spring-boot-starter-webflux를 포함하고, MVC 관련 의존성은 제거한다. 

두 방식이 혼재하면 동기와 비동기간의 충돌로 예상치 못한 오류가 발생할 수 있다. 

    // webflux
    implementation 'org.springframework.boot:spring-boot-starter-webflux'

    // security
    implementation "org.springframework.boot:spring-boot-starter-security"

    // Swagger for WebFlux
    implementation 'org.springdoc:springdoc-openapi-starter-webflux-ui:2.6.0'
    
    // jwt
    implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.12.3'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.12.3'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.12.3'

    // mongodb
    implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'

 

 

2. Swagger 설정

WebFlux 환경에서도 Swagger를 사용하기 위해 OpenAPI 설정을 추가한다.

import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@RequiredArgsConstructor
public class SwaggerConfig {

    @Bean
    public OpenAPI openAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("API Documentation")
                        .description("API description")
                        .version("v1.0.0"))
                // Security Requirement를 추가
                .addSecurityItem(new SecurityRequirement().addList("BearerAuth"))
                .components(new Components()
                        .addSecuritySchemes("BearerAuth", new SecurityScheme()
                                .type(SecurityScheme.Type.HTTP)  // HTTP 타입
                                .scheme("bearer")              // Bearer 토큰 사용
                                .bearerFormat("JWT")));        // JWT 토큰 포맷 명시
    }

}

 

 

3. JWT 기반 인증 필터 (JwtRequestFilter)

WebFlux에서는 WebFilter를 활용하여 인증 필터를 구현한다.
ReactiveSecurityContextHolder를 이용해 인증 정보를 비동기적으로 관리해야 한다.

import com.fasterxml.jackson.databind.ObjectMapper;
import com.test.config.domain.MemberContext;
import com.test.config.domain.PrincipalDetails;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;

import java.util.Collections;
import java.util.List;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtRequestFilter implements WebFilter {

    private final JwtTokenProvider jwtTokenProvider;

    private static final ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 토큰의 인증정보를 SecurityContext 안에 저장하는 역할 수행
     */
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        String token = getBearerToken(exchange.getRequest());
        if (token != null) {
            MemberContext memberContext = jwtTokenProvider.getMember(token);

            // 로그 출력
            log.info("login - token : {}", token);
            log.info("login - ldapId : {}", memberContext.getLdapId());

            // 권한 설정
            String authorityType = memberContext.getAuthorityType();
            GrantedAuthority authority = new SimpleGrantedAuthority(authorityType);
            PrincipalDetails principalDetails = new PrincipalDetails(memberContext);
            memberContext.setMemberRole(authorityType);

            Authentication authentication = new UsernamePasswordAuthenticationToken(
                    principalDetails, null, Collections.singletonList(authority)
            );

            // Reactor Context에 인증 정보 저장
            return chain.filter(exchange)
                    .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication));
        }

        return chain.filter(exchange);
    }

    private String getBearerToken(ServerHttpRequest request) {
        List<String> authHeaders = request.getHeaders().get(HttpHeaders.AUTHORIZATION);
        if (authHeaders != null && !authHeaders.isEmpty() && authHeaders.get(0).startsWith("Bearer ")) {
            return authHeaders.get(0).substring(7);
        }
        return null;
    }

}

 

 

4. JWT 인증 실패 처리 (JwtAuthenticationEntryPoint)

인증 실패 시 클라이언트에 적절한 오류 응답을 반환하는 역할로 JWT 인증 흐름에서 중요한 부분이다. 

import com.fasterxml.jackson.databind.ObjectMapper;
import com.test.config.enums.ErrorCodeEnum;
import com.test.config.response.ErrorResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@Component
@Slf4j
public class JwtAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {

    private static final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException authException) {
        log.error("Unauthorized access: {}", authException.getMessage());

        // ErrorResponse 객체 생성
        ErrorResponse errorResponse = new ErrorResponse(ErrorCodeEnum.AUTH_FAIL);

        // JSON 응답 생성
        String jsonResponse;
        try {
            jsonResponse = objectMapper.writeValueAsString(errorResponse);
        } catch (Exception e) {
            log.error("Error while serializing ErrorResponse: {}", e.getMessage());
            jsonResponse = "{\"errorCode\":\"INTERNAL_ERROR\",\"errorMessage\":\"An unexpected error occurred\"}";
        }

        // 응답 설정
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);

        byte[] bytes = jsonResponse.getBytes();

        // 논블로킹 방식으로 응답 작성
        return exchange.getResponse()
                .writeWith(Mono.just(exchange.getResponse()
                        .bufferFactory()
                        .wrap(bytes)));
    }
    
}

 

 

5. 인가 실패 처리 (JwtAccessDeniedHandler)

권한이 없는 사용자가 접근하려고 할 경우 403 Forbidden 응답을 반환한다.

import com.fasterxml.jackson.databind.ObjectMapper;
import com.test.config.enums.ErrorCodeEnum;
import com.test.config.response.ErrorResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@Component
@Slf4j
public class JwtAccessDeniedHandler implements ServerAccessDeniedHandler {

    private static final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException deniedException) {
        log.error("Access denied: {}", deniedException.getMessage());

        // ErrorResponse 객체 생성
        ErrorResponse errorResponse = new ErrorResponse(ErrorCodeEnum.FORBIDDEN);

        // JSON 응답 생성
        String jsonResponse;
        try {
            jsonResponse = objectMapper.writeValueAsString(errorResponse);
        } catch (Exception e) {
            log.error("Error while serializing ErrorResponse: {}", e.getMessage());
            jsonResponse = "{\"errorCode\":\"INTERNAL_ERROR\",\"errorMessage\":\"An unexpected error occurred\"}";
        }

        // 응답 설정
        exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
        exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);

        byte[] bytes = jsonResponse.getBytes();

        // 논블로킹 방식으로 응답 작성
        return exchange.getResponse()
                .writeWith(Mono.just(exchange.getResponse()
                        .bufferFactory()
                        .wrap(bytes)));    }
}

 

 

6. API 경로별 권한 설정 (SecurityWebFilterChain)

WebFlux에서는 SecurityWebFilterChain을 사용하여 권한을 설정합니다.

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
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.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;

@Slf4j
@Configuration
@EnableWebFluxSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtRequestFilter jwtRequestFilter;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;


    // whiteList 적용 
    private static final String[] ALL_WHITELIST = {
            "/api/health_check", "/api/v1/auth/login",
            "/swagger", "/swagger-resources/**", "/v3/api-docs/**", "/swagger-ui/**"
    };

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        return http
                .csrf(ServerHttpSecurity.CsrfSpec::disable)
                .authorizeExchange(auth -> auth
                        .pathMatchers(ALL_WHITELIST).permitAll()
                        .anyExchange().authenticated()
                )
                .addFilterAt(jwtRequestFilter, SecurityWebFiltersOrder.AUTHENTICATION)
                .exceptionHandling(exceptions -> exceptions
                        .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                        .accessDeniedHandler(jwtAccessDeniedHandler)
                )
                .build();
    }


    @Bean
    public CorsWebFilter corsWebFilter() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.addAllowedOriginPattern("*"); // 모든 Origin 허용 (배포 시 제한 필요)
        configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용
        configuration.addAllowedHeader("*"); // 모든 헤더 허용
        configuration.setAllowCredentials(true); // 자격 증명 포함 허용
        configuration.setMaxAge(600L); // PreFlight 요청 캐시 시간 (초 단위)

        // 노출할 헤더 설정
        configuration.addExposedHeader("Content-Disposition");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);

        return new CorsWebFilter(source);
    }
    
}

 

 

 

7. block() 호출이 Reactive 흐름에 미치는 영향

WebFlux에서는 block()을 사용하면 Reactive 스트림의 비동기 흐름이 깨지고, Security Context가 손실될 수 있다. 

따라서 모든 인증 및 권한 관리는 Mono 또는 Flux를 활용한 비동기 방식으로 처리해야 한다. 

    @Operation(summary = "menu 정보 조회", description = "menu 정보 조회")
    @GetMapping("/menu")
    public Mono<ResponseEntity<BaseResponse>> getMenuList(
            @AuthenticationPrincipal PrincipalDetails principalDetails,
            ServerHttpRequest request
    ) {
        return menuService.getSubMenuIdAndSearchFields()
                .map(data -> new ResponseEntity<>(new BaseResponse(data), HttpStatus.OK));
    }

 

 

 

 

WebFlux 환경에서는 인증 및 인가 로직이 비동기적으로 동작해야 하며, block() 호출을 피해야 한다.
이를 고려하여 Reactor Context를 활용한 인증 처리가 필요하다. 
728x90
반응형