Webflux+Spring Security+JWT Simple하게 구현하기

dev-well-being·2023년 6월 14일
5
post-thumbnail

Servlet기반의 Spring에서 Spring Security와 JWT을 연동하여 설정하여 사용했었다.

Webflux에서도 동일하게 구현가능할까 알아보았고 메소드는 조금 달랐지만 기존에 구현하였던 기능들을 구현할 수 있었다.

Spring Security 설정하기

아래와 같이 Spring Security 라이브러리를 gradle에 추가한다.

dependencies {

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

아래와 같이 Security 관련 메소드들을 선언할 클래스을 하나 생성해준다. @EnableWebFluxSecurity는 Security를 활성화하겠다고 선언하는 것이다.

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

그리고 해당 클래스에서 SecurityWebFilterChain을 아래와 같이 Bean으로 설정해준다.

	@Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        http.csrf().disable()
            .cors().disable()
            .formLogin().disable()
            .httpBasic().disable()
            .securityContextRepository(NoOpServerSecurityContextRepository.getInstance()) //session STATELESS
            .exceptionHandling()
                .authenticationEntryPoint(serverAuthenticationEntryPoint())
            .and()
            .authorizeExchange()
                .pathMatchers(NO_AUTH_LIST)
                .permitAll()
                .anyExchange()
                .authenticated()
            .and()
            .anonymous().disable()
            .logout().disable()
            .headers()
                .frameOptions()
                    .mode(XFrameOptionsServerHttpHeadersWriter.Mode.SAMEORIGIN)
            .and()
            .addFilterBefore(authenticationWebFilter(), SecurityWebFiltersOrder.AUTHENTICATION)
        ;
        return http.build();
    }

Servlet기반의 Security와 가장 달랐던 부분이 바로 Session 설정 부분인데 Servlet기반의 Security에서는 sessionManagement()는 메소드를 제공해서 거기서 Session 관련 설정을 하였다. 하지만 Webflux Security에서는 해당 메소드를 제공하지 않았고 Session을 STATELESS로 설정하기 위해서는 주석부분처럼 선언해야 한다고 한다.

아래는 Spring Security에서 Exception이 발생할 경우 어떻게 처리해야 할지 정의한 부분이다. 에러가 발생할 경우 ErrorMessage 를 reponse body 담아 리턴하도록 하였다.

private ServerAuthenticationEntryPoint serverAuthenticationEntryPoint(){
        return (exchange, authEx) -> {
            String requestPath = exchange.getRequest().getPath().value();

            log.error("Unauthorized error: {}", authEx.getMessage());
            log.error("Requested path    : {}", requestPath);

            ServerHttpResponse serverHttpResponse = exchange.getResponse();
            serverHttpResponse.getHeaders().setContentType(MediaType.APPLICATION_JSON);
            serverHttpResponse.setStatusCode(HttpStatus.UNAUTHORIZED);

            ErrorMessage errorMessage = new ErrorMessage(HttpStatus.UNAUTHORIZED.value()
                    , LocalDateTime.now()
                    , authEx.getMessage()
                    , requestPath);

            try {
                byte[] errorByte = new ObjectMapper()
                        .registerModule(new JavaTimeModule())
                        .writeValueAsBytes(errorMessage);
                DataBuffer dataBuffer = serverHttpResponse.bufferFactory().wrap(errorByte);
                return serverHttpResponse.writeWith(Mono.just(dataBuffer));
            } catch (JsonProcessingException e) {
                log.error(e.getMessage(), e);
                return serverHttpResponse.setComplete();
            }
        };
    }

JWT 설정하기

JWT와 관련 라이브러리를 gradle에 설정해준다.

dependencies {

    //jwt
    implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2', 'io.jsonwebtoken:jjwt-jackson:0.11.2'

JwtProvider 클래스에 JWT 관련 메소드들을 정의하고 @Component로 선언한다.

@Component
@Slf4j
public class JwtProvider {
    @Value("${app.jwt-secret}")
    private String jwtSecret;


    public String getToken(ServerHttpRequest request){
        return request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
    }

    public String resolveToken(ServerHttpRequest request){
        String bearerToken = getToken(request);

        if(!StringUtils.isBlank(bearerToken) && bearerToken.startsWith("Bearer")){
            return bearerToken.substring(7);
        }

        return null;
    }

    public boolean validateJwtToken(String authToken) throws UnAuthenticationException{
        try {
            Jwts.parserBuilder().setSigningKey(jwtSecret).build().parseClaimsJws(authToken);
            return true;
        } catch (SignatureException e) {
            log.error("Invalid JWT signature: {}", e.getMessage());
            throw new UnAuthenticationException("Invalid JWT signature: "+e.getMessage());
        } catch (ExpiredJwtException e) {
            log.error("JWT token is expired: {}", e.getMessage());
            throw new UnAuthenticationException("JWT token is expired: "+e.getMessage());
        } catch (UnsupportedJwtException e) {
            log.error("JWT token is unsupported: {}", e.getMessage());
            throw new UnAuthenticationException("JWT token is unsupported: "+e.getMessage());
        } catch (IllegalArgumentException e) {
            log.error("JWT claims string is empty: {}", e.getMessage());
            throw new UnAuthenticationException("JWT claims string is empty: "+e.getMessage());
        }catch (JwtException e){
            log.error("Invalid JWT token: {}", e.getMessage());
            throw new UnAuthenticationException("Invalid JWT token: "+e.getMessage());
        }
    }

    public Authentication getAuthentication(String accessToken) throws UnAuthenticationException{
        Claims claims = parseClaims(accessToken);

        if(claims.get("adminYn") == null){
            throw new UnAuthenticationException("Token without permission information");
        }

        Collection<? extends GrantedAuthority> authorities = Arrays
                .stream(claims.get("adminYn").toString().split(","))
                .map(SimpleGrantedAuthority::new)
                .toList();

        User user = User.of()
                .setId(claims.get("userId"))
                .setName(claims.get("username").toString());

        return new UsernamePasswordAuthenticationToken(user, "", authorities);
    }

    private Claims parseClaims(String accessToken){
        try {
            return Jwts.parserBuilder().setSigningKey(jwtSecret).build().parseClaimsJws(accessToken).getBody();
        } catch(ExpiredJwtException e) {
            return e.getClaims();
        }
    }

}

Spring + JWT 연동하기

아래는 위에서 Bean으로 등록해주었던 SecurityWebFilterChain에서 addFilterBefore에서 호출하는 메소드들도 매 호출마다 JWT를 확인하고 Security의 Authentication을 설정해주는 부분이다.

 	private final JwtProvider jwtProvider;

    private AuthenticationWebFilter authenticationWebFilter() {
        ReactiveAuthenticationManager authenticationManager = Mono::just;

        AuthenticationWebFilter authenticationWebFilter
                = new AuthenticationWebFilter(authenticationManager);
        authenticationWebFilter.setServerAuthenticationConverter(serverAuthenticationConverter());
        return authenticationWebFilter;
    }

    private ServerAuthenticationConverter serverAuthenticationConverter(){
        return exchange -> {
            String token = jwtProvider.resolveToken(exchange.getRequest());
            try {
                if(!Objects.isNull(token) && jwtProvider.validateJwtToken(token)){
                    return Mono.justOrEmpty(jwtProvider.getAuthentication(token));
                }
            } catch (UnAuthenticationException e) {
                log.error(e.getMessage(), e);
            }
            return Mono.empty();
        };
    }

이 부분도 Servlet과 많이 달랐는데 Servlet에서는 OncePerRequestFilter클래스를 상속받아 doFilterInternal 메소드에다가 filter 로직을 구현하였다.

Webflux에서는 AuthenticationWebFilter의 converter를 활용하여 JWT를 받아 Authentication을 설정해주었다.

profile
안녕하세요!! 좋은 개발 문화를 위해 노력하는 dev-well-being 입니다.

0개의 댓글