Git Product home page Git Product logo

spring-webflux-template's Introduction

Spring WebFlux REST Template


Spring Webflux 사용을 위한 기본 Template을 정리하였습니다.

Framework 및 개발환경

  • Java 1.8 later
  • Spring Boot 2.2.x
  • Spring WebFlux
  • Spring Data JPA & QueryDSL
  • Spring Cache with Redis
  • Spring Security with JWT
  • MySQL 8.0.19 & Redis 5.0.8 with Docker

Annotated Controllers vs Functional Endpoints

Spring WebFlux는 Spring MVC에서 사용하던 Annotation 기반에 @Controller 엔드포인트와 FP 기반의 Functional Endpoints의 두 가지 방식을 모두 다 지원합니다. 어느것을 사용해도 상관없지만 REST API에 필요한 다양한 설정 및 Validation 그리고 Documentation 을 위한 Swagger 설정 등에 편의성 때문에 Annotated Controller 방식을 사용하기로 합니다.


기본 Configuration

  • application.yaml 을 통해 아래와 같이 Spring Boot 기본 구성을 설정합니다.
#기본 로깅 설정
logging:
  level:
    root: DEBUG
    org.springframework.web.reactive.function.client.ExchangeFunctions: DEBUG   #Webclient의 log()를 위해 설정
    org.hibernate.type.descriptor.sql: trace
  file:
    path: target/logs

#서버 port 설정
server:
  port: 8080

spring:
  profiles:
    active: local   # 기본 profile을 개발용 local로 설정
  datasource:
    hikari:
      minimum-idle: 3
      maximum-pool-size: 10
      connection-timeout: 30000
      idle-timeout: 600000
      validation-timeout: 40000
    sql-script-encoding: UTF-8
    initialization-mode: always   # local 개발시에만 schema.sql과 data.sql을 로딩하고 불필요시 never로 변경
  jpa:
    database: mysql
    database-platform: org.hibernate.dialect.MySQL8Dialect
    show-sql: true
    hibernate:
      ddl-auto: validate
    properties:
      hibernate.show_sql: true
      hibernate.use_sql_comments: true
      hibernate.format_sql: true
      hibernate.query.in_clause_parameter_padding: true   # Prepared Statement 성능 향상을 위해 Where IN Clause의 개수를 padding
    open-in-view: false     # REST용 Framework이므로 OSIV 해제
  jackson:
    default-property-inclusion: non_empty
    serialization:
      WRITE_DATES_AS_TIMESTAMPS: false    # Jackson Serialization 시 LocalDateTime 등 타입 출력 ISO 8601 포멧으로 변경
  • WebFluxConfig 를 통해 아래와 같이 WebFlux 기본 설정을 Override 합니다.
@Configuration
@EnableWebFlux
public class WebFluxConfig implements WebFluxConfigurer {

    @Value("${spring.datasource.hikari.maximum-pool-size}")
    private int connectionPoolSize;

    //Swagger 설정을 위한 static resource 설정
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/swagger-ui.html**")
                .addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**")
                .addResourceLocations("classpath:/META-INF/resources/webjars/");
    }

    //WebFlux를 아직 지원하지 않는 MySQL에 Transaction 처리를 위해 별도 Scheduler 설정
    @Bean("jdbcScheduler")
    public Scheduler jdbcScheduler() {
        return Schedulers.fromExecutor(Executors.newFixedThreadPool(connectionPoolSize));
    }

    //WebFlux 상에서 JPA LazyInitialize 문제가 발생 시 직접 Transaction을 관리하기 위한 template 로딩
    @Bean
    public TransactionTemplate transactionTemplate(PlatformTransactionManager transactionManager) {
        return new TransactionTemplate(transactionManager);
    }
}

암호화 설정

Property나 Database 등에서 양방향 암복호화를 위한 Encryption 설정을 아래와 같이 구성합니다.

  • pom.xml 에 암호화 관련 라이브러리를 추가합니다 (jasypt-spring-boot-starter 활용)
<!-- Encryption Library -->
<dependency>
    <groupId>com.github.ulisesbocchio</groupId>
    <artifactId>jasypt-spring-boot-starter</artifactId>
    <version>3.0.2</version>
</dependency>
<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcprov-jdk15on</artifactId>
    <version>1.64</version>
</dependency>
  • EncryptConfig를 통해 암호화 키 및 알고리즘 등을 설정합니다.
@Configuration
public class EncryptConfig {

    @Bean("jasyptStringEncryptor")
    public StringEncryptor stringEncryptor() {
        PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();
        SimpleStringPBEConfig config = new SimpleStringPBEConfig();
        config.setPassword("{CUSTOM_PASSWORD}");    //적용할 패스워드를 넣는다 
        config.setAlgorithm("PBEWithSHA1AndDESede");
        config.setKeyObtentionIterations("1000");
        config.setPoolSize("1");
        config.setProvider(new BouncyCastleProvider());
        config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator");
        config.setStringOutputType("base64");
        encryptor.setConfig(config);
        return encryptor;
    }
}
  • Application에서 위에서 정의한 빈을 통해 별도 암복호화가 가능하고
public class EncryptConfigTest {

    @Autowired
    private StringEncryptor jasyptStringEncryptor;

    @Test
    public void testEncrypt() {
        String originString = "EncryptConfigTest";

        String encryptedString = jasyptStringEncryptor.encrypt(originString);
        log.info("##### encrypted string : {}", encryptedString);
        
        String decryptedString = jasyptStringEncryptor.decrypt(encryptedString); 
        log.info("##### decrypted string : {}", decryptedString);
    }

}
  • property file도 암호화된 문자열을 ENC() 형태로 감싸서 적용할 수 있습니다.
spring:
  profiles: local
  datasource:
    url: jdbc:mysql://localhost:13306/testDB?useUnicode=yes&characterEncoding=UTF-8
    username: ENC(iz8p6xZ6Or+gbMphJu8VsHIHwNGKNgVW)     #암호화된 유저정보 
    password: ENC(tDUfykZXyTthimgZT35ECw+GpX0y/TZz)     #암호화된 패스워드
    driver-class-name: com.mysql.cj.jdbc.Driver
  • JPA Entity에 암복호화가 필요한 필드에 대해서도 AttributeConverter를 통해 자동 암복호화가 가능합니다.

아래와 같이 AttributeConverter 를 정의하고

@Converter
public class StringEncryptConverter implements AttributeConverter<String, String> {

    private static StringEncryptor stringEncryptor;

    @Autowired
    @Qualifier("jasyptStringEncryptor")
    public void setStringEncryptor(StringEncryptor encryptor) {
        StringEncryptConverter.stringEncryptor = encryptor;
    }

    @Override
    public String convertToDatabaseColumn(String entityString) {

        return Optional.ofNullable(entityString)
                       .filter(s -> !s.isEmpty())
                       .map(StringEncryptConverter.stringEncryptor::encrypt)
                       .orElse("");
    }

    @Override
    public String convertToEntityAttribute(String dbString) {

        return Optional.ofNullable(dbString)
                       .filter(s -> !s.isEmpty())
                       .map(StringEncryptConverter.stringEncryptor::decrypt)
                       .orElse("");
    }
}

Entity에 Converter를 적용합니다.

@Entity
@Table(name = "user")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {

    @Id
    private String userId;

    //자동으로 DB 암복호화가 가능
    @Convert(converter = StringEncryptConverter.class)
    private String password;
}

Scheduling & Async 설정

Spring에 @Scheduled를 사용한 배치 작업이나 @Async를 활용한 비동기 API 호출 작업을 위해서는 @Configuration 클래스에 @EnableScheduling@EnableAsync 를 추가해주면 별도의 설정 없이 사용 가능하며 각각의 TaskScheduler를 설정하거나 Error Handler를 설정하기 위해서는 다음과 같이 Configure를 통해 설정을 재정의 합니다.

  • SchedulingConfig
@Configuration
@EnableScheduling
public class SchedulingConfig implements SchedulingConfigurer {

    @Value("${schedule.threadPool.size:30}")
    private int poolSize;

    @Autowired
    @Qualifier("scheduledErrorHandler")
    private ErrorHandler errorHandler;

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();

        scheduler.setPoolSize(poolSize);
        scheduler.setThreadNamePrefix("Task-Scheduler-");
        scheduler.setErrorHandler(errorHandler);
        scheduler.initialize();

        taskRegistrar.setTaskScheduler(scheduler);
    }
}
  • AsyncConfig
@Configuration
@EnableAsync
public class AsyncConfig extends AsyncConfigurerSupport {

    @Value("${async.threadPool.size:20}")
    private int poolSize;

    @Value("${async.threadPool.max:100}")
    private int maxPoolSize;

    @Value("${async.threadPool.keepAliveSeconds:60}")
    private int keepAliveSeconds;

    @Value("${async.threadPool.queueCapacity:1000}")
    private int queueCapacity;

    @Override
    public Executor getAsyncExecutor() {

        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(poolSize);
        executor.setMaxPoolSize(maxPoolSize);
        executor.setKeepAliveSeconds(keepAliveSeconds);
        executor.setQueueCapacity(queueCapacity);
        executor.setThreadNamePrefix("Async-");
        executor.initialize();

        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new AsyncExceptionHandler();
    }
}

@Scheduled@Async가 설정된 메소드들은 에러가 발생할 경우 사후 처리가 어렵기 때문에 위 예시에서 보이는것과 같이 별도의 ExceptionHandler를 재작성하여 Fallback 처리를 하거나 Webhook 등을 발송 하여 에러를 처리 하는 것이 좋습니다.


i18n과 에러메시지 처리

Spring WebFlux의 LocaleResolver는 AcceptHeaderLocaleContextResolverFixedLocaleContextResolver를 통해 Accept-Language 와 TimeZone 에 기반한 Locale 을 인지합니다. 따라서 QueryParam 과 Accept-Language 를 동시에 지원하기 위해 다음과 같이 LocaleContextResolver를 작성하여 DelegatingWebFluxConfiguration 를 통해 LocaleResolver를 등록해 줘야 합니다.

  • LocaleResolver
public class LocaleResolver implements LocaleContextResolver {

    @Override
    public LocaleContext resolveLocaleContext(ServerWebExchange exchange) {

        String language = exchange.getRequest().getQueryParams().getFirst("lang") != null
                        ? exchange.getRequest().getQueryParams().getFirst("lang")
                          : exchange.getRequest().getHeaders().getFirst(HttpHeaders.ACCEPT_LANGUAGE);

        Locale targetLocale = language != null && !language.isEmpty()
                              ? Locale.forLanguageTag(language.replaceAll("_", "-"))
                              : Locale.getDefault();

        return new SimpleLocaleContext(targetLocale);
    }

    @Override
    public void setLocaleContext(ServerWebExchange exchange, LocaleContext localeContext) {

    }
}
  • LocaleConfig
@Configuration
public class LocaleConfig extends DelegatingWebFluxConfiguration {

    @Override
    protected LocaleContextResolver createLocaleContextResolver() {
        return new LocaleResolver();
    }
}

Spring WebFlux에서 에러 처리는 ResponseStatusException 을 REST API에서 발생시켜 주면 DefaultErrorAttributes 를 통해 JSON으로 반환되지만 i18n을 적용하기 위해서는 별도의 Exception 정의하고 @RestControllerAdvice 또는 @ControllerAdvice 통해서 Response 메시지를 재정의 해주는 것이 좋습니다.

어플리케이션에서 사용할 기본 Exception인 AppBaseException 과 에러코드 정의를 위한 ErrorCode 를 정의하고 Response 구조체인 AppErrorResponse 반환하는 @RestControllerAdvice 를 아래와 같이 작성하여
어플리케이션에서 발생한 에러에 대한 i18n을 적용합니다.

  • GlobalAdvice
@RestControllerAdvice
@RequiredArgsConstructor
@Slf4j
public class GlobalAdvice {

    private final MessageSource messageSource;

    @ExceptionHandler({AppBaseException.class})
    public ResponseEntity<AppErrorResponse> handleAppBaseException(AppBaseException ex, Locale locale, ServerWebExchange exchange) {

        if(ex.getCause() != null) {
            log.error(ex.getLocalizedMessage(), ex);
        }

        HttpStatus status = ex.getStatus() != null
                            ? ex.getStatus()
                            : HttpStatus.BAD_REQUEST;

        String errorMessage = ex.getErrorCode() != null
                              ? messageSource.getMessage(ex.getErrorCode().getValue(), ex.getArgs(), locale)
                              : null;

        return new ResponseEntity<>(
            AppErrorResponse.builder()
                            .timestamp(System.currentTimeMillis())
                            .path(exchange.getRequest().getPath().value())
                            .status(status.value())
                            .error(status.getReasonPhrase())
                            .message(errorMessage != null ? errorMessage : ex.getLocalizedMessage())
                            .requestId(exchange.getRequest().getId())
                            .build()
            , status
        );
    }
}

MessageSource를 위한 i18n message들은 application.yaml에 다음과 같이 정의 하고 해당 디렉토리 (classpath:/resource/i18n) 아래에 지원하는 Locale 별로 파일을 생성합니다. (messages.properties, messages_en_US.properties, messages_ko_KR.properties 등)

  • application.yaml
spring:
    messages:
        basename: i18n/messages
        encoding: UTF-8
        use-code-as-default-message: true

어플리케이션 내부에서 사용하는 메시지 또한 MessageSourceLocale을 통해 i18n을 적용 할 수 있습니다.


Cache with Redis

Spring WebFlux에 Redis를 적용하기 위한 방식은 기본적으로 Spring MVC 방식과 동일하며, appliction.yaml을 통해 Redis, Lettuce, Cache 관련 정보를 설정 해주면 바로 사용할 수 있습니다. Reactive Template들을 추가로 사용 가능하게 하기 위해서 Reactive 관련 Bean을 추가로 설정 해 줍니다.

  • application.yaml
spring:
  redis:
    host: localhost
    port: 16379
    password: ENC(RE0TFEbdNyRMb+IwbeLLvrhKBAjKmStj)
    lettuce:
      pool:
        min-idle: 2
        max-idle: 5
        max-active: 10
  cache:
    type: redis
    redis:
      cache-null-values: false
      time-to-live: 60000

Spring Boot을 통해 Lettuce Connection Pool을 사용하기 위해서는 Apache Common Pool을 Maven에 추가해 줘야 합니다.

  • pom.xml
<!--Common Pool -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    <version>2.8.0</version>
</dependency>

@Cacheable 어노테이션을 통한 Cache를 사용하기 위해서는 다음과 같이 @EnableCaching을 설정합니다.

  • CacheConfig
@Configuration
@EnableCaching
public class CacheConfig {
}

Redis 설정 및 Template 재정의를 위해 다음과 같이 @Configuration을 추가해 줍니다.

  • RedisConfig
@Configuration
@EnableRedisRepositories
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(factory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return redisTemplate;
    }

    @Bean
    public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(ReactiveRedisConnectionFactory factory) {

        RedisSerializationContext<String, Object> serializationContext =
            RedisSerializationContext.<String, Object>newSerializationContext(new StringRedisSerializer())
                                     .hashKey(new StringRedisSerializer())
                                     .hashValue(new GenericJackson2JsonRedisSerializer())
                                     .build();

        return new ReactiveRedisTemplate<>(factory, serializationContext);
    }
}

** 현재 버전의 Spring WebFlux는 @Cacheable 어노테이션을 통한 Mono 또는 Flux의 캐싱을 지원하지 않습니다. Mono 또는 Flux에 대한 캐싱을 적용하기 위해서는 reactor-extra에서 지원하는 CacheMono 등을 통해 직접 구현해야 합니다.

  • pom.xml
<!-- Reactor Extra for Reactive Cache -->
<dependency>
    <groupId>io.projectreactor.addons</groupId>
    <artifactId>reactor-extra</artifactId>
</dependency>

캐시 구현은 아래와 같이 Util 클래스를 설정하고 API에서 활용하는 것이 편합니다.

  • Util Class Method (RxUtil.class 참고)
public static <T> Mono<T> cacheMono(CacheManager cacheManager, String cacheName, String key, Mono<T> retriever, Class<T> klass) {
    return
        CacheMono
            .lookup(
                k -> RxUtil.elasticMono(() -> Mono.justOrEmpty(cacheManager.getCache(cacheName).get(k, klass)).map(Signal::next) )
                , key
            )
            .onCacheMissResume(retriever)
            .andWriteWith((k, sig) ->
                RxUtil.elasticMono(() -> Optional.ofNullable(sig.get()).map(Mono::just).orElseGet(Mono::empty)
                                                 .doOnNext(o -> cacheManager.getCache(cacheName).put(k, o))
                                                 .then()
                )
            )
        ;
}
  • WebFlux Cache Usage
@Service
@Slf4j
@RequiredArgsConstructor
public class UserSimpleService implements UserService {

    private final UserRepository userRepository;
    private final CacheManager cacheManager;

    @Qualifier("jdbcScheduler")
    private final Scheduler jdbcScheduler;

    @Transactional(readOnly = true)
    public Mono<User> findById(@NonNull String userId) {

        return
            RxUtil.cacheMono(
                cacheManager
                , "user"
                , userId
                , Mono.defer(() -> 
                        userRepository.findById(userId)
                                      .map(Mono::just)
                                      .orElseGet(Mono::empty)
                      )
                      .subscribeOn(jdbcScheduler)
                , User.class
            )
            ;
    }
}

WebClient 설정

Spring WebFlux에서 제공하는 HTTP/1.1 기반의 Non-Blocking, Reactive Http Client로 WebClient를 지원하며 기존 Spring MVC에 RestTemplate을 대체하여 사용할 수 있습니다 (RestTemplate은 향후 Deprecated될 예정입니다.)

WebClient 를 사용하기 위해 @Bean으로 등록하고 아래와 같이 기본값들을 변경합니다.

  • WebClientConfig
@Configuration
@Slf4j
public class WebClientConfig {

    @Bean
    public WebClient webClient() {

        ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder()
                                                                  .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024*1024*50))  //Body Contents 용량 증가
                                                                  .build();
        exchangeStrategies
            .messageWriters().stream()
            .filter(LoggingCodecSupport.class::isInstance)
            .forEach(writer -> ((LoggingCodecSupport)writer).setEnableLoggingRequestDetails(true));     //Logging 설정

        return WebClient.builder()
                        .clientConnector(
                            new ReactorClientHttpConnector(     // HttpClient 옵션 수정
                                HttpClient
                                    .create()
                                    .secure(
                                        ThrowingConsumer.unchecked(
                                            sslContextSpec -> sslContextSpec.sslContext(
                                                SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build()    //비인증 SSL 허가
                                            )
                                        )
                                    )
                                    .tcpConfiguration(
                                        client -> client.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 120_000)                      //Connection TimeOut
                                                        .doOnConnected(conn -> conn.addHandlerLast(new ReadTimeoutHandler(180))     //Read TimeOut
                                                                                   .addHandlerLast(new WriteTimeoutHandler(180))    //Write TimeOut
                                                        )
                                    )
                            )
                        )
                        .exchangeStrategies(exchangeStrategies)
                        .filter(ExchangeFilterFunction.ofRequestProcessor(      //Request Header Logging
                            clientRequest -> {
                                log.debug("Request: {} {}", clientRequest.method(), clientRequest.url());
                                clientRequest.headers().forEach((name, values) -> values.forEach(value -> log.debug("{} : {}", name, value)));
                                return Mono.just(clientRequest);
                            }
                        ))
                        .filter(ExchangeFilterFunction.ofResponseProcessor(     //Response Header Logging
                            clientResponse -> {
                                clientResponse.headers().asHttpHeaders().forEach((name, values) -> values.forEach(value -> log.debug("{} : {}", name, value)));
                                return Mono.just(clientResponse);
                            }
                        ))
                        .defaultHeader("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.87 Safari/537.3")
                        .build();
    }
}

사용법은 위에서 설정한 Bean을 mutate() 메소드를 통해 옵션을 Override한 새로운 client를 생성해서 이용합니다.

  • Sample Usage
@Service
@Slf4j
@RequiredArgsConstructor
public class SampleRestService implements SampleService {

    @Value("${restKey}")
    private String restKey;

    private final WebClient webClient;

    public Mono<SampleResponse> search(SampleParam param) {

        return
            webClient.mutate()
                     .baseUrl("https://api.sample.com")
                     .build()
                     .get()
                     .uri("/search/something?query={QUERY}&sort={SORT}&page={PAGE}&size={SIZE}&target={TARGET}"
                         , param.getQuery()
                         , param.getSort() 
                         , param.getPage() 
                         , param.getSize() 
                         , param.getTarget() 
                     )
                     .accept(MediaType.APPLICATION_JSON)
                     .header(HttpHeaders.AUTHORIZATION, restKey)
                     .retrieve()
                     .onStatus(status -> status.is4xxClientError() || status.is5xxServerError()
                         , clientResponse -> clientResponse.bodyToMono(String.class).map(body -> new AppBaseException(body)))
                     .bodyToMono(SampleResponse.class)
                     .log()
            ;
    }
}

Spring Security with JWT

Spring WebFlux + Spring Security + JWT를 조합한 가장 심플한 구성의 예시입니다.

기본 전략은 다음과 같습니다.

  • JWT Token은 보안상 가장 최소한의 정보만 담습니다.
  • JWT Token 자체에 기본 보안이 되어 있으므로 Token Validation 시 User 정보를 조회하지 않습니다.
  • User 정보를 추가로 검증하려면 AuthenticationJwtManager에 검증 로직을 추가합니다.
  • Token이 유효한지 자체는 Token 자체 만으로 검증합니다.
  • 유효 Token 리스트를 별도로 저장하려면 BearerSecurityContextRepository에 검증 로직을 추가합니다.
  • User의 가입 / 로그인 과 Token의 생성 / 재발급만 예시로 작성하였습니다.
  • JWT 토큰은 auth0 라이브러리를 활용합니다.

Spring Security를 적용하기 위해 pom.xml에 관련 라이브러리를 추가해 줍니다.

  • pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- JWT -->
    <dependency>
        <groupId>com.auth0</groupId>
        <artifactId>java-jwt</artifactId>
        <version>3.10.0</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>

</dependencies>

@Configuration을 통해 SecurityWebFilterChainPasswordEncoder를 설정해 줍니다.

  • SecurityConfig
@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class SecurityConfig {

    @Autowired
    private AuthenticationJwtManager authenticationManager;

    @Autowired
    private BearerSecurityContextRepository securityContextRepository;

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {

        return
            http
                .exceptionHandling()
                    .authenticationEntryPoint((exchange, e) -> Mono.fromCallable(() -> exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED)).then())
                    .accessDeniedHandler((exchange, denied) -> Mono.fromCallable(() -> exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN)).then())
                .and()
                .httpBasic().disable()
                .formLogin().disable()
                .logout().disable()
                .cors().disable()
                .csrf().disable()
                .authenticationManager(authenticationManager)
                .securityContextRepository(securityContextRepository)
                .authorizeExchange()
                    .pathMatchers("/api/ping/**").permitAll()
                    .pathMatchers("/api/test/**").permitAll()
                    .pathMatchers("/api/auth/sign*").permitAll()
                    .pathMatchers(HttpMethod.OPTIONS).permitAll()
                    .pathMatchers("/api/**").authenticated()
                .anyExchange().permitAll()
                .and()
                .build();

    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

JWT 토큰 인증과 Method Security 적용을 위해 ReactiveAuthenticationManagerServerSecurityContextRepository을 사용자 지정하여 적용하였습니다.

각 구현체는 아래와 같습니다.

  • ReactiveAuthenticationManager
@Component
@RequiredArgsConstructor
public class AuthenticationJwtManager implements ReactiveAuthenticationManager {

    private final TokenService tokenService;

    @Override
    public Mono<Authentication> authenticate(Authentication authentication) {

        return
            Mono.just(authentication)
                .map(auth -> auth.getCredentials().toString())
                .flatMap(token -> Mono.just(tokenService.verifyToken(token)))       // JWT Token 검증
                .flatMap(decodedJWT -> Mono.just(decodedJWT)                        // 필요시 User 추가 검증 가능
                                           .map(jwt -> jwt.getClaim("scopes").asList(String.class).stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList()))
                                           .map(authorities ->
                                                new UsernamePasswordAuthenticationToken(
                                                    User.builder()
                                                        .username(decodedJWT.getSubject())
                                                        .password("Noop")
                                                        .authorities(authorities)
                                                        .build()
                                                    , null
                                                    , authorities
                                                    )
                                            )
                )
                .onErrorResume(throwable -> Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED, throwable.getLocalizedMessage())))
                .flatMap(Mono::just)
            ;
    }
}
  • ServerSecurityContextRepository
@Component
@RequiredArgsConstructor
public class BearerSecurityContextRepository implements ServerSecurityContextRepository {

    private final ReactiveAuthenticationManager authenticationManager;

    @Override
    public Mono<Void> save(ServerWebExchange exchange, SecurityContext context) {
        return Mono.empty();
    }

    @Override
    public Mono<SecurityContext> load(ServerWebExchange exchange) {

        return
            RxUtil.elasticMono(() -> Optional.ofNullable(exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION))
                                             .filter(s -> s.startsWith("Bearer "))
                                             .map(s -> s.substring(7))
                                             .map(Mono::just)
                                             .orElseGet(Mono::empty)
                  )
                  .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not found token, Please signIn again.")))
                  .map(token -> new UsernamePasswordAuthenticationToken(token, token))      //필요시 Token 추가 검증
                  .flatMap(authenticationManager::authenticate)
                  .map(SecurityContextImpl::new)
                  ;
    }
}

MethodSecurity는 다음과 같이 사용할 수 있으며, SecurityContext에서 User정보를 가져오기 위해서는 @AuthenticationPrincipal 을 통해 파라미터로 받는게 좋습니다. (Spring WebFlux의 Context는 여기 를 참고하면 좋습니다.)

  • Usage
@GetMapping("/refresh")
@PreAuthorize("hasAuthority('refresh')")
@ApiImplicitParam(name = "Authorization", value = "Bearer Token", required = true, paramType = "header", dataTypeClass = String.class, example = "Bearer ...")
public Mono<TokenInfo> refresh(@ApiIgnore @AuthenticationPrincipal UserDetails userInfo) {

    return
        Mono.justOrEmpty(userInfo)
            .map(UserDetails::getUsername)
            .flatMap(userService::findById)
            .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not found token, Please signin again.")))
            .flatMap(user -> RxUtil.elasticMono(() -> Mono.just(tokenService.createToken(user))))
        ;
}

spring-webflux-template's People

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.