AuthenticationManager 방식의 JWT 인증
ReactiveAuthenticationManager 기반의 인증

ReactiveAuthenticationManager 기반의 인증

스프링 시큐리티 관련 개념을 문서를 정리할 때마다 항상 고통스럽고 설명이 쉽지 않았었는데요. 최대한 쉽게 융통성 있게 설명해보겠습니다.

이번 글에서는 SecurityConfig 를 필터체인을 Bean 으로 등록하는 방식을 설명합니다. WebSecurityConfgiurerAdapter 를 상속해서 configure 메서드를 오버라이딩 하던 2.7.x 버전대의 시큐리티 코드 작성 방식은 이 글에서 다루지 않기로 했습니다. 저 역시도 이 방식을 본지가 꽤 오래되어서 기억이 안나기도 하고 지금와서 다시 예제를 하나 더 추가하기에는 시간도 부족해서 다루지 않기로 했습니다.

예제의 내용은 id/pw 기반으로 인증을 하고 있는 단순한 내용입니다.
email/password 기반으로 할까 하다가 예제가 산만해질것 같아서 id/pw 기반의 예제를 작성하는 것으로 결정했습니다.


코드

모든 코드는 https://github.com/chagchagchag/webflux-mongo-mysql-redis/tree/main/foobar-user (opens in a new tab) 에 있는 코드입니다.


의존성

jwt 인증을 사용할 것이기에 jwt 인증과 security 의존성을 추가했습니다.

dependencies{
    // ...
    
    // security
	implementation("org.springframework.boot:spring-boot-starter-security")
 
	// jwt
	implementation("io.jsonwebtoken:jjwt-api:0.11.2")
	implementation("io.jsonwebtoken:jjwt-impl:0.11.2")
	implementation("io.jsonwebtoken:jjwt-jackson:0.11.2")
    
    // ...
}

SecurityConfig.java

먼저 아래와 같은 SecurityConfig 클래스를 작성합니다. SecurityConfig 클래스 코드를 먼저 보는 이유는 어떤 기능을 추가할지 명확하게 보여주기 때문에 SecurityConfig 코드를 먼저 추가했습니다.

package io.chagchagchag.example.foobar.user.config.security;
 
// ...
 
@RequiredArgsConstructor
@EnableWebFluxSecurity // (0)
@Configuration // (0)
public class SecurityConfig {
 
  @Bean
  public SecurityWebFilterChain filterChain(
      ServerHttpSecurity httpSecurity,
      JwtServerAuthenticationConverter converter, // (1)
      JwtAuthenticationManager authenticationManager // (1)
  ){
    // (1)
    var authenticationWebFilter = new AuthenticationWebFilter(authenticationManager);
    authenticationWebFilter.setServerAuthenticationConverter(converter); // (1)
 
    // (2)
    return httpSecurity
        // (3)
        .exceptionHandling(exceptionHandlingSpec ->
            exceptionHandlingSpec.authenticationEntryPoint(
                (exchange, ex) -> Mono.fromRunnable(() -> {
                  exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
                  exchange.getResponse().getHeaders().set(HttpHeaders.WWW_AUTHENTICATE, "Bearer");
                })
            )
        )
        .csrf(csrfSpec -> csrfSpec.disable()) // (4)
        .formLogin(formLoginSpec -> formLoginSpec.disable()) // (4)
        .httpBasic(httpBasicSpec -> httpBasicSpec.disable()) // (4)
        // (5)
        .authorizeExchange(authorizeExchangeSpec ->
            authorizeExchangeSpec
                .pathMatchers("/", "/welcome", "/img/**", "/api/users/signup", "/healthcheck/**")
                .permitAll()
                .pathMatchers("/swagger-ui.html", "/webjars/**")
                .permitAll()
                .pathMatchers("/healthcheck/ready")
                .permitAll()
                .pathMatchers("/api/users/login", "/api/users/signup")
                .permitAll()
                .pathMatchers("/logout", "/api/users/profile/**")
                .hasAnyAuthority("ROLE_USER", "ROLE_MANAGER", "ROLE_ADMIN")
        )
        // (6)
        .headers(headerSpec -> headerSpec.frameOptions(frameOptionsSpec -> frameOptionsSpec.disable()))
        // (7)
        .addFilterAt(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION)
        .build();
  }
}

(0)

@RequiredArgsConstructor
@EnableWebFluxSecurity // (0)
@Configuration // (0)
public class SecurityConfig {
    // ...
}

@EnableWebFluxSecurity, @Configuration 을 추가해줍니다. @EnableWebFluxSecurity 는 ServerHttpSecurityConfiguration.class, WebFluxSecurityConfiguration.class, ReactiveOAuth2ClientImportSelector.class 설정을 포함하는 설정입니다.


(1)

AuthenticationWebFilter 객체를 생성합니다. 그리고 이 AuthenticationWebFilter 객체에는 직접 커스텀하게 구현해둔 JwtAuthenticationManager 객체를 바인딩해줍니다. 위의 코드에서는 AuthenticationWebFilter 생성자에 JwtAuthenticationManager 객체를 바인딩해줬습니다.

직접 작성한 클래스인 JwtAuthenticationManager 클래스의 내용은 이글의 하단부에 설명 예정입니다.


(2) HttpSecurity httpSecurity

HttpSecurity httpSecurity 빈을 주입받은 후 httpSecurity객체의 build() 함수를 이용해서 SecurityWebFilterChain을 빈으로 등록합니다.


(3)

httpSecurity
// (3)
.exceptionHandling(exceptionHandlingSpec ->
    exceptionHandlingSpec.authenticationEntryPoint(
        (exchange, ex) -> Mono.fromRunnable(() -> {
          exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); // (i)
          exchange.getResponse().getHeaders().set(HttpHeaders.WWW_AUTHENTICATE, "Bearer"); // (ii)
        })
    )
)

(i)

  • 인증이 실패했을 때 StatusCode 를 어떤 것으로 내려줄지를 정의합니다.

(ii)

  • Response 헤더에는 { WWW-Authenticate : Bearer }을 지정해주었습니다.

(4)

httpSecurity
// ...
// (3)
.csrf(csrfSpec -> csrfSpec.disable()) // (i)
.formLogin(formLoginSpec -> formLoginSpec.disable()) // (ii)
.httpBasic(httpBasicSpec -> httpBasicSpec.disable()) // (iii)
// ...

(i) : csrf를 비활성화합니다.

(ii) : formLogin 을 비활성화 합니다.

(iii) : httpBasic 역시 비활성화 합니다.


(5)

http
// ...
// (5)
.authorizeExchange(authorizeExchangeSpec ->
    authorizeExchangeSpec
        // (i)
        .pathMatchers("/", "/welcome", "/img/**", "/api/users/signup", "/healthcheck/**")
        .permitAll()
        // (ii)
        .pathMatchers("/swagger-ui.html", "/webjars/**")
        .permitAll()
        // (iii)
        .pathMatchers("/healthcheck/ready")
        .permitAll()
        // (iv)
        .pathMatchers("/api/users/login", "/api/users/signup")
        .permitAll()
        // (v)
        .pathMatchers("/logout", "/api/users/profile/**")
        .hasAnyAuthority("ROLE_USER", "ROLE_MANAGER", "ROLE_ADMIN")
)

(i), (ii), (iii), (iv)

  • "/", "/welcome", "/img/**", "/api/users/signup", "/healthcheck/**" 에 대해서는 permitAll 해줍니다.

  • "/swagger-ui.html", "/webjars/**" 에 대해서 permitAll 해줍니다.

  • "/healthcheck/ready" 에 대해서도 permitAll 해줍니다.

  • "/api/users/login", "/api/users/signup" 에 대해서도 역시 permitAll 해줍니다.

(v)

  • "/logout", "/api/users/profile/**" 에 대해서는 "ROLE_USER", "ROLE_MANAGER", "ROLE_ADMIN" 의 권한에 대해서만 허용합니다.

(6)

X-Frame-Options 헤더를 적용합니다. X-Frame-Options는 웹 페이지를 <frame>, <iframe>, <embed>, <object>와 같은 태그를 사용하여 다른 웹 사이트에 삽입되는 것을 방지하는 데 사용합니다. 궁금하시다면 Chat GPT에게 Spring security 에서 headerSpec.frameOptions 는 무슨 기능을 해? 라고 질문해보시기 바랍니다.

http
// ...
// (6)
.headers(headerSpec -> headerSpec.frameOptions(frameOptionsSpec -> frameOptionsSpec.disable()))
// ...

(7)

위에서 생성해둔 authenticationWebFilter 를 httpSecurity 객체에 추가해 준 후 HttpSecurity 의 build() 메서드를 통해 SecurityWebFilterChain 객체를 생성합니다.

http
// ...
// (7)
.addFilterAt(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION)
.build();

JwtAuthenticationManager

직접 작성한 JwtAuthenticationManager 클래스는 ReactiveAuthenticationManagerimplements 해서 직접 작성한 클래스입니다. 이 글의 하단 부에 AuthenticationManager, AuthenticationManagerBuilder, HttpSecurity, AuthenticationManagerProvider 가 어떻게 상호작용하는지를 설명합니다.

package io.chagchagchag.example.foobar.user.config.security;
 
// ...
 
@RequiredArgsConstructor
@Component
public class JwtAuthenticationManager implements ReactiveAuthenticationManager {
  // (1)
  private final JwtSupport jwtSupport; // (1)
  private final CustomUserDetailsService userDetailsService; // (1)
 
  // (2)
  @Override
  public Mono<Authentication> authenticate(Authentication authentication) {
    return Mono.justOrEmpty(authentication)
        .filter(auth -> auth instanceof BearerToken)
        .cast(BearerToken.class)
        .map(bearerToken -> degenerateToken(bearerToken))
        .flatMap(jwtDto -> validateJwt(jwtDto))
        .flatMap(jwtDto -> findUserById(jwtDto.id()))
        .onErrorMap(throwable -> new IllegalArgumentException("INVALID JWT"));
  }
 
  // (3) 간단한 설명이기에 설명은 생략
  public JwtDto degenerateToken(BearerToken token){
    return jwtSupport.degenerateToken(SecurityProperties.key, token.getJwt());
  }
 
  // (4) 간단한 내용이기에 설명은 생략
  public Mono<JwtDto> validateJwt(JwtDto jwtDto){
    if(jwtSupport.checkIfNotExpired(jwtDto.expiration())){
      return Mono.just(jwtDto);
    }
    return Mono.error(new IllegalArgumentException("Token Invalid"));
  }
 
  // (5)
  private Mono<Authentication> findUserById(String userId){
    return userDetailsService
        .findByUsername(userId)
        .map(userDetails -> {
          var authentication = new UsernamePasswordAuthenticationToken(
              userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities()
          );
          SecurityContextHolder.getContext().setAuthentication(authentication);
          return authentication;
        });
  }
  
}

(1)

@RequiredArgsConstructor
@Component
public class JwtAuthenticationManager implements ReactiveAuthenticationManager {
  // (1)
  private final JwtSupport jwtSupport; // (1)
  private final CustomUserDetailsService userDetailsService; // (1)
    
  // ...
}
  • JwtSupport 는 직접 정의했던 JwtSupport 클래스입니다. 자세한 내용은 JWT 생성, 분해 예제 (opens in a new tab) 에 정리해두었습니다.
  • CustomUserDetailsService 를 바인딩해줬습니다. CustomUserDetailsService 에 대한 내용은 이글의 하단에 추가해두었습니다.

(2)

ReactiveAuthenticationManager interface의 authenticate(Authentication authentication) 을 override 하는 예제입니다.

@RequiredArgsConstructor
@Component
public class JwtAuthenticationManager implements ReactiveAuthenticationManager {
  // ...
  // (2)
  @Override
  public Mono<Authentication> authenticate(Authentication authentication) {
    return Mono.justOrEmpty(authentication)
        .filter(auth -> auth instanceof BearerToken)
        .cast(BearerToken.class)
        .map(bearerToken -> degenerateToken(bearerToken))
        .flatMap(jwtDto -> validateJwt(jwtDto))
        .flatMap(jwtDto -> findUserById(jwtDto.id()))
        .onErrorMap(throwable -> new IllegalArgumentException("INVALID JWT"));
  }
  // ...
}

  • .filter(auth -> auth instanceof BearerToken)

    • BearerToken 객체 타입인지 검사합니다. BearerToken 은 직접 정의한 객체입니다. 이 문서의 하단에 설명을 추가해두었습니다.
  • .cast(BearerToken.class)

    • 현재 필터에 들어온 Authentication 객체를 BearerToken 타입으로 변환해줍니다.
  • .map(bearerToken -> degenerateToken(bearerToken))

    • bearerToken 이라는 이름의 Authentication 객체를 JwtDto 로 변환해줍니다.
    • degenerateToken(Authentication) 메서드는 (3) 에서 설명합니다.
  • .flatMap(jwtDto -> validateJwt(jwtDto))

    • validateJwt(jwtDto)에 대해서 변환되어진 jwtDto 에 대해서 Jwt 가 expiration 이 만료되었는지를 검사합니다.
  • .flatMap(jwtDto -> findUserById(jwtDto.id()))

    • validation 이 끝난 JwtDto 객체에 대해서 findUserById(...) 메서드를 통해서 실제 Database에 저장된 사용자인지를 검증합니다.
  • .onErrorMap(throwable -> new IllegalArgumentException("INVALID JWT"));

    • 에러가 났을 때는 어떤 Exception 을 낼지 정의합니다.

(5)

@RequiredArgsConstructor
@Component
public class JwtAuthenticationManager implements ReactiveAuthenticationManager {
  // ...
  
  // (5)
  private Mono<Authentication> findUserById(String userId){
    return userDetailsService
        .findByUsername(userId)
        .map(userDetails -> {
          var authentication = new UsernamePasswordAuthenticationToken(
              userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities()
          );
          SecurityContextHolder.getContext().setAuthentication(authentication);
          return authentication;
        });
  }
    
  // ...
}

UserDetailsService 에서 findByUsername(userId) 를 통해서 userId에 대해서 사용자가 존재하는지 조회합니다. 조회 결과로 찾아낸 사용자에 대해서 Authentication 객체인 UsernamePasswordAuthenticationToken 으로 생성하고 이 객체를 SecurityContextHolder 에 Authentication 객체를 저장합니다.

그리고 새롭게 업데이트 된 Authentication 객체를 return 하는 것으로 메서드의 실행을 마무리합니다.


JwtServerAuthenticationConverter

이 JwtServerAuthenticationConverter 는 위의 SecurityConfig 코드 내에서 등록할 때에는 var authenticationWebFilter = new AuthenticationWebFilter(authenticationManager); 을 통해 생성한 AuthenticationWebFilter 객체에 대해 authenticationWebFilter.setServerAuthenticationConverter(converter); 코드를 통해서 컨버터를 등록합니다.


package io.chagchagchag.example.foobar.user.config.security;
 
import io.chagchagchag.example.foobar.dataaccess.user.security.BearerToken;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
 
@RequiredArgsConstructor
@Component
public class JwtServerAuthenticationConverter implements ServerAuthenticationConverter {
 
  @Override
  public Mono<Authentication> convert(ServerWebExchange exchange) {
    return Mono.justOrEmpty(exchange.getRequest())
        .flatMap(serverHttpRequest -> Mono.justOrEmpty(serverHttpRequest.getHeaders()))
        .flatMap(httpHeaders -> Mono.justOrEmpty(httpHeaders.getFirst(HttpHeaders.AUTHORIZATION)))
        .filter(headerValue -> checkContainsBearer(headerValue))
        .flatMap(jwt -> Mono.justOrEmpty(new BearerToken(jwt)));
  }
 
  public Boolean checkContainsBearer(String header){
    var len = "Bearer ".length();
    return header.substring(0, len).equalsIgnoreCase("Bearer");
  }
 
}
 

convert() 함수의 내용이 길어보이지만 serverHttpRequest.getHeaders().getFirst(HttpHeaders.AUTHORIZATION) 으로 얻은 String 내에 Bearer 문자열이 있는지를 검사하는 코드입니다. Null 체크를 해야 하기에 null 이 생길수 있는 부분들을 각각 Mono.flatMap 과 Mono.justOrEmpty() 로 분리해서 연결했습니다.


BearerToken

위의 코드에서 사용한 BearerToken 은 아래와 같습니다. AbstractAuthenticationToken 객체를 상속한 클래스이고 SecurityFilterChain 에 등록된 필터가 검사할때는 Authentication 객체를 통해 검사를 수행하게 됩니다. (AbstractAuthenticationToken 객체는 Authentication 인터페이스를 implements 한 클래스입니다.)

package io.chagchagchag.example.foobar.dataaccess.user.security;
 
import lombok.Getter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
 
@Getter
public class BearerToken extends AbstractAuthenticationToken {
  private final String jwt;
  public BearerToken(String jwt){
    super(AuthorityUtils.NO_AUTHORITIES);
    this.jwt = jwt;
  }
 
  @Override
  public Object getCredentials() {
    return jwt;
  }
 
  @Override
  public Object getPrincipal() {
    return jwt;
  }
}

CustomUserDetailsService

package io.chagchagchag.example.foobar.user.config.security;
 
import io.chagchagchag.example.foobar.dataaccess.user.repository.UserR2dbcRepository;
import io.chagchagchag.example.foobar.dataaccess.user.security.UserDetailsMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
 
@RequiredArgsConstructor
@Component
public class CustomUserDetailsService implements ReactiveUserDetailsService {
  private final UserR2dbcRepository userR2dbcRepository;
  private final UserDetailsMapper userDetailsMapper;
 
  // id 가 존재하는지만 검사하고, 이것을 UserDetails 로 반환한다.
  @Override
  public Mono<UserDetails> findByUsername(String userId) {
    return userR2dbcRepository
        .findById(Long.parseLong(userId))
        .map(userEntity -> userDetailsMapper.defaultUserDetails(userEntity));
  }
 
}

UserDetailsMapper

UserDetailsMapper 내의 defaultUserDetails 메서드는 아래와 같이 정의해두었습니다.

// ...
 
import org.springframework.security.core.userdetails.User;
// ...
 
@Component
public class UserDetailsMapper {
  public User defaultUserDetails(UserEntity userEntity){
    return new User(
        String.valueOf(userEntity.getId()),
        userEntity.getPassword(),
        true, true, true, true,
        new ArrayList<GrantedAuthority>()
    );
  }
}