본문 바로가기

SpringFramework

스프링 웹플럭스 인증 with JWT + RBAC

스프링 웹플럭스와 JWT를 이용하여 무상태(stateless) 인증과 RBAC 개념을 적용한 예제를 만들어 보았다. 소스 대부분의 인터넷의 예제를 참고 하였으며 동작에 대한 기술은 웹사이트나 소스를 직접 보고 확인해 보았다. DB나 JPA 설정등은 생략한다.

 

스프링 부트 자체가 워낙 자동설정으로 되어 있다보니 내부적으로 어떻게 동작하는지 파악하기가 쉽지 않았다는... 핑계고, 스프링의 애노테이션이 어떻게 동작하는지에 대한 기본 지식들이 아직도 부족해서다. 기본사용은 쉬우나 목적에 맞게 설정하기 위해서는 프레임워크의 동작을 어느정도 잘 이해해야 한다. 돌아서면 잊어먹는 나이가 되었기에 나중에라도 잘 활용하기 위핸 파악한 내용들을 최대한 정리해 본다. 내용정리를 잘해보려고 하였으나 시간이 너무 걸려 의식에 흐름에 따라 기술한다.

 

기본 플로우는 사용자가 로그인 후, 토큰을 발급 받고, 발급 받은 토큰을 이용하여 원하는 서비스를 호출한다. 인증 레벨에서는 사용자 요청에 유효한 토큰이 있는지 확인 후, 토큰이 유효할 경우 인증 정보를 생성하고 요청에 맞는 핸들러 메소드를 실행시킨다. 인증 정보는 퍼미션(permission 또는 privilege) 정보를 담고 있는데, 핸들러 메소드가 실행되기 전에 요청한 사용자가 메소드를 실행시킬 수 있는 권한이 있는지 체크후, 권한이 있을 경우에만 메소드가 실행될 수 있도록 한다.

 

먼저 스프링 시큐리티 설정들을 모아둘 Configuration 클래스 ReactiveSecurityConfig를 생성한다. 아래 코드는 ReactiveSecurityConfig 클래스의 전체 코드이며, 각 빈마다 순차적으로 알아 본다.

package com.sthwin.hulk.config;

import com.sthwin.hulk.component.JwtTokenProvider;
import com.sthwin.hulk.entity.UsUserMasterEntity;
import com.sthwin.hulk.filter.JwtTokenAuthenticationFilter;
import com.sthwin.hulk.model.CustomUserDetails;
import com.sthwin.hulk.repository.UsUserMasterRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository;
import reactor.core.publisher.Mono;

import java.io.Serializable;
import java.util.List;

/**
 * 보안에 필요한 것들을 여기에 작성한다.
 * <p>
 * authentication manager, security context repository, 허용이 필요한 url등.
 * <p>
 *
 *  @EnableReactiveMethodSecurity은 DefaultMethodSecurityExpressionHandler클래스를 애플리케이션 컨텍스트에 등록해 준다.
 *  methodSecurityExpressionHandler
 */

@Configuration
@RequiredArgsConstructor
@EnableReactiveMethodSecurity
public class ReactiveSecurityConfig {

    private final ApplicationContext applicationContext;

    /**
     * ServerHttpSecurity는 스프링 시큐리티의 HttpSecurity와 비슷한 웹플럭스용 클래스다.
     * 이 클래스를 이용하여 모든 요청에 대해 인증 여부 체크를 정의할 수 있다.
     * 이 클래스에 필터를 추가하여, 요청에 인증용 토큰이 존재할 경우 인증이 되도록 설정할 수 있다.
     *
     * SecurityWebFilterChain클래스를 생성하기 전에 DefaultMethodSecurityExpressionHandler클래스가 먼저 구성되어 있어야 한다.
     * <p>
     * authenticationEntryPoint: 애플리케이션이 인증을 요청할 때 해야 할 일들을 정의함.
     * accessDeniedHandler: 인증된 사용자가 필요한 권한을 가지고 있을 않을 때 헤야 할 일들을 정의함.
     *
     * @param http
     * @return
     */
    @Bean
    @DependsOn({"methodSecurityExpressionHandler"})
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
                                                         JwtTokenProvider jwtTokenProvider,
                                                         ReactiveAuthenticationManager reactiveAuthenticationManager) {
        DefaultMethodSecurityExpressionHandler defaultWebSecurityExpressionHandler = this.applicationContext.getBean(DefaultMethodSecurityExpressionHandler.class);
        defaultWebSecurityExpressionHandler.setPermissionEvaluator(myPermissionEvaluator());
        return http
                .exceptionHandling(exceptionHandlingSpec -> exceptionHandlingSpec
                        .authenticationEntryPoint((exchange, ex) -> {
                            return Mono.fromRunnable(() -> {
                                exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
                            });
                        })
                        .accessDeniedHandler((exchange, denied) -> {
                            return Mono.fromRunnable(() -> {
                                exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
                            });
                        }))
                .csrf().disable()
                .formLogin().disable()
                .httpBasic().disable()
                .authenticationManager(reactiveAuthenticationManager)
                .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
                .authorizeExchange(exchange -> exchange
                        .pathMatchers(HttpMethod.OPTIONS).permitAll()
                        .pathMatchers("/login").permitAll()
                        .anyExchange().authenticated()
                )
                .addFilterAt(new JwtTokenAuthenticationFilter(jwtTokenProvider), SecurityWebFiltersOrder.HTTP_BASIC)
                .build();
    }

    @Bean
    public PermissionEvaluator myPermissionEvaluator() {
        return new PermissionEvaluator() {
            @Override
            public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
                if(authentication.getAuthorities().stream()
                        .filter(grantedAuthority -> grantedAuthority.getAuthority().equals(targetDomainObject))
                        .count() > 0)
                    return true;
                return false;
            }

            @Override
            public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
                return false;
            }
        };
    }


    @Bean
    public ReactiveUserDetailsService userDetailsService(UsUserMasterRepository usUserMasterRepository) {
        return username -> {
            UsUserMasterEntity usUserMaster = usUserMasterRepository.findUsUserMasterEntityByUsername(username);
            if (usUserMaster == null)
                return Mono.empty();

            CustomUserDetails userDetails = new CustomUserDetails();
            userDetails.setUsername(usUserMaster.getUsername());
            userDetails.setPassword(usUserMaster.getPassword());
            userDetails.setEnabled(usUserMaster.isActive());
            userDetails.setAccountNonExpired(usUserMaster.isActive());
            userDetails.setCredentialsNonExpired(usUserMaster.isActive());
            userDetails.setAccountNonLocked(usUserMaster.isActive());

            List<String> permissions = userDetails.getPermissions();
            usUserMaster.getUsUserRoleEntityList().stream().forEach(
                    usUserRoleEntity -> {
                        usUserRoleEntity.getUsRoleEntity().getUsRolePermissionEntityList().stream().forEach(
                                usRolePermissionEntity -> permissions.add(usRolePermissionEntity.getUsPermissionEntity().getPermissionCode())
                        );
                    });
            return Mono.just(userDetails);
        };
    }

    @Bean
    public ReactiveAuthenticationManager reactiveAuthenticationManager(ReactiveUserDetailsService userDetailsService,
                                                                       PasswordEncoder passwordEncoder) {
        var authenticationManager = new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService);
        authenticationManager.setPasswordEncoder(passwordEncoder);
        return authenticationManager;
    }

}

@EnableReactiveMethodSecurity을 선언했는데 메소드 레벨에서 권한을 체크하도록 설정하기 위해 필요하다.

다음은 보안 설정 클래스에 보안에 필요한 빈들을 설정한다.

SecurityWebFilterChain 빈 설정

웹플럭스에 스프링 시큐리티를 등록하기 위해서는 기본적으로 SecurityWebFilterChain 클래스를 빈으로 등록해야 한다. SecurityWebFilterChain은 웹플럭스용 보안 필터 체인 클래스다. @EnableWebFluxSecurity를 선언해 줄 경우, 자동적으로 아래와 같은 설정으로 SecurityWebFilterChain 클래스가 구성된다.

@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
    http
         .authorizeExchange()
              .anyExchange().authenticated()
                   .and()
              .httpBasic().and()
              .formLogin();
    return http.build();
}

@EnableWebFluxSecurity를 명시적으로 사용해도 되고 사용하지 않아도 된다. SecurityWebFilterChain 클래스를 빈으로 등록만 해주면 스프링 내부적으로 SecurityWebFilterChain 에 설정된 WebFilter들을 애플리케이션 컨텍스트에 등록해 준다.

		// ReactiveSecurityConfig.class
		@Bean
    @DependsOn({"methodSecurityExpressionHandler"})
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
                                                         JwtTokenProvider jwtTokenProvider,
                                                         ReactiveAuthenticationManager reactiveAuthenticationManager) {
        DefaultMethodSecurityExpressionHandler defaultWebSecurityExpressionHandler = this.applicationContext.getBean(DefaultMethodSecurityExpressionHandler.class);
        defaultWebSecurityExpressionHandler.setPermissionEvaluator(myPermissionEvaluator());
        return http
                .exceptionHandling(exceptionHandlingSpec -> exceptionHandlingSpec
                        .authenticationEntryPoint((exchange, ex) -> {
                            return Mono.fromRunnable(() -> {
                                exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
                            });
                        })
                        .accessDeniedHandler((exchange, denied) -> {
                            return Mono.fromRunnable(() -> {
                                exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
                            });
                        }))
								.cors().disable()
                .csrf().disable()
                .formLogin().disable()
                .httpBasic().disable()
                .authenticationManager(reactiveAuthenticationManager)
                .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
                .authorizeExchange(exchange -> exchange
                        .pathMatchers(HttpMethod.OPTIONS).permitAll()
                        .pathMatchers("/login").permitAll()
                        .anyExchange().authenticated()
                )
                .addFilterAt(new JwtTokenAuthenticationFilter(jwtTokenProvider), SecurityWebFiltersOrder.HTTP_BASIC)
                .build();
    }

ServerHttpSecurity 을 다음과 같이 빌드해 준다.

  • stateless방식의 애플리케이션이 되도록 설정한다 - .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
  • 인증여부를 체크해 줄 수 있는 리액티브용 인증매니저를 등록한다 - .authenticationManager(reactiveAuthenticationManager)
  • 인가받을 것과 받지 않을 URI 들을 authorizeExchange 메소드를 통해 설정해 준다. 기본적으로 로그인은 인증없이 접근될 수 있도록 설정한다.
  • 모든 요청에 대하여 토큰을 체크하고 인증을 만들어줄 필터를 등록한다 - .addFilterAt(new JwtTokenAuthenticationFilter(jwtTokenProvider), SecurityWebFiltersOrder.HTTP_BASIC))

ServerHttpSecurity는 보안에 필요한 여러 필터들을 생성하는데 사용된다. ServerHttpSecurity 를 빌드하게 되면 ServerHttpSecurity는 빌드전 입력받은 인자들을 이용해서 필터를 생성하고 순서에 맞게 필터를 배치한다. 정렬되는 필터의 순서는 다음 클래스를 보면 확인 할 수 있다. (참고로 몰랐던 부분인데 enum 상수들은 정의된 순서대로 가져올 수 있다.)

public enum SecurityWebFiltersOrder {
	FIRST(Integer.MIN_VALUE),
	HTTP_HEADERS_WRITER,
	/**
	 * {@link org.springframework.security.web.server.transport.HttpsRedirectWebFilter}
	 */
	HTTPS_REDIRECT,
	/**
	 * {@link org.springframework.web.cors.reactive.CorsWebFilter}
	 */
	CORS,
	/**
	 * {@link org.springframework.security.web.server.csrf.CsrfWebFilter}
	 */
	CSRF,
	/**
	 * {@link org.springframework.security.web.server.context.ReactorContextWebFilter}
	 */
	REACTOR_CONTEXT,
	/**
	 * Instance of AuthenticationWebFilter
	 */
	HTTP_BASIC,
	... 생략....
}

ReactiveAuthenticationManager

ReactiveSecurityConfig.class 내에 ServerHttpSecurity에 설정 할 ReactiveAuthenticationManager 빈을 다음과 같이 설정한다.

@Bean
public ReactiveAuthenticationManager reactiveAuthenticationManager(ReactiveUserDetailsService userDetailsService,
                                                                   PasswordEncoder passwordEncoder) {
    var authenticationManager = new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService);
    authenticationManager.setPasswordEncoder(passwordEncoder);
    return authenticationManager;
}

위의 코드를 보면 ReactiveAuthenticationManager 인터페이스를 구현한 UserDetailsRepositoryReactiveAuthenticationManager클래스를 생성해서 반환하고 있는데 이 클래스의 클래스 다이어그램은 다음과 같다.

UserDetailsRepositoryReactiveAuthencticationManager 클래스 다이어그램

UserDetailsRepositoryReactiveAuthenticationManager 클래스는 내부적으로 ReactiveAuthenticationManager 인터페이스를 구현한 AbstractUserDetailsReactiveAuthenticationManager 클래스를 상속받고 있다. 이 클래스의 public 메소드들을 확인해 보면 다음과 같다.

위메소드를 보면 UserDetailsRepositoryReactiveAuthenticationManager 클래스는 인증 체크에 필요한 클래스로 ReactiveUserDetailsService서비스 클래스와 PasswordEncoder 클래스를 필요로 하는 것으로 보인다. UserDetailsRepositoryReactiveAuthenticationManager 클래스는 사용자 정보를 조회해오기 위해 ReactiveUserDetailsService 인터페이스를 구현한 클래스를 사용하며, 클라이언트로 부터 전달 받은 평문 암호를 암호화 하기 위해서 PasswordEncoder 인터페이스를 구현한 클래스를 필요로한다. 이 두가지는 시스템별로 달라지는 부분으로 시스템에 맞게 구현하여 UserDetailsRepositoryReactiveAuthenticationManager 클래스 생성시 함께 설정해 준다. 이 클래스의 소스는 다음과 같다.

/**
 * A {@link ReactiveAuthenticationManager} that uses a {@link ReactiveUserDetailsService}
 * to validate the provided username and password.
 *
 * @author Rob Winch
 * @author Eddú Meléndez
 * @since 5.0
 */
public class UserDetailsRepositoryReactiveAuthenticationManager
		extends AbstractUserDetailsReactiveAuthenticationManager {

	private ReactiveUserDetailsService userDetailsService;

	public UserDetailsRepositoryReactiveAuthenticationManager(ReactiveUserDetailsService userDetailsService) {
		Assert.notNull(userDetailsService, "userDetailsService cannot be null");
		this.userDetailsService = userDetailsService;
	}

	@Override
	protected Mono<UserDetails> retrieveUser(String username) {
		return this.userDetailsService.findByUsername(username);
	}

}

클래스의 주석을 보면, UserDetailsRepositoryReactiveAuthenticationManager 클래스는 리액티브용 사용자 정보 조회 서비스인 ReactiveUserDetailsService 를 사용하기 위한 클래스로 생각하면 될것 같다.

참고로 살펴본 부분은 @EnableWebFluxSecurity 선언 부분인데, @EnableWebFluxSecurity를 굳이 선언하지 않더라도 클래스 패스에 Flux.class, EnableWebFluxSecurity.class, WebFilterChainProxy.class, WebFluxConfigurer.class들이 존재하면 자동으로 @EnableWebFluxSecurity 선언된 것처럼 보안관련 클래스들이 자동으로 로드된다. 스프링의 자동 설정 기능 때문인데. 다음 클래스를 보면 알 수 있다.
/**
 *{@linkEnableAutoConfigurationAuto-configuration}for Spring Security in a reactive
 * application. Switches on{@linkEnableWebFluxSecurity@EnableWebFluxSecurity}for a
 * reactive web application if this annotation has not been added by the user. It
 * delegates to Spring Security's content-negotiation mechanism for authentication. This
 * configuration also backs off if a bean of type{@linkWebFilterChainProxy}has been
 * configured in any other way.
 *
 *@authorMadhura Bhave
 *@since2.0.0
 */
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(SecurityProperties.class)
@ConditionalOnClass({Flux.class, EnableWebFluxSecurity.class, WebFilterChainProxy.class, WebFluxConfigurer.class})
public class ReactiveSecurityAutoConfiguration{

	@Configuration(proxyBeanMethods = false)
	@ConditionalOnMissingBean(WebFilterChainProxy.class)
	@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
	@EnableWebFluxSecurity
   	static class EnableWebFluxSecurityConfiguration{
   	}
}

ReactiveUserDetailsService

ReactiveSecurityConfig.class 내에 ReactiveAuthenticationManager에 설정할 ReactiveUserDetailsService 빈을 설정한다.

먼저 ReactiveUserDetailsService에 대해 알아보면 이 클래스는 Mono<UserDetails> 를 반환하는 역할을 한다. UserDetails는 사용자의 기본정보를 접근할 수 있게 해주는 인터페이스다. 소스는 다음과 같다.

/**
 * An API for finding the {@link UserDetails} by username.
 *
 * @author Rob Winch
 * @since 5.0
 */
public interface ReactiveUserDetailsService {

	/**
	 * Find the {@link UserDetails} by username.
	 * @param username the username to look up
	 * @return the {@link UserDetails}. Cannot be null
	 */
	Mono<UserDetails> findByUsername(String username);

}

UserDetails 클래스는 사용자의 기본 정보들에 접근할 수 있는 인터페이스다. 인터페이스이므로 요구사항에 맞게 구현해야 한다. 구현 코드는 다음과 같다. DB에서 조회한 사용자 정보를 스펙에 맞춰 제공해 줄 수 있도록 구현해주면 된다.

@Getter
@Setter
public class CustomUserDetails implements UserDetails {

    private String username;
    private String password;
    private boolean accountNonExpired;
    private boolean accountNonLocked;
    private boolean credentialsNonExpired;
    private boolean enabled;

    private List<String> permissions = new ArrayList<>();

    public CustomUserDetails() {
    }

    public CustomUserDetails(String username) {
        this.username = username;
    }

    @Override
    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return accountNonExpired;
    }

    @Override
    public boolean isAccountNonLocked() {
        return accountNonLocked;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return credentialsNonExpired;
    }

    @Override
    public boolean isEnabled() {
        return this.enabled;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        permissions.stream().forEach(permission -> {
            authorities.add(new SimpleGrantedAuthority(permission));
        });
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

CustomUserDetails를 반환하는 ReactiveUserDetailsService 빈을 다음과 같이 생성한다.

@Bean
public ReactiveUserDetailsService userDetailsService(UsUserMasterRepository usUserMasterRepository) {
    return username -> {
        UsUserMasterEntity usUserMaster = usUserMasterRepository.findUsUserMasterEntityByUsername(username);
        if (usUserMaster == null)
            return Mono.empty();

        CustomUserDetails userDetails = new CustomUserDetails();
        userDetails.setUsername(usUserMaster.getUsername());
        userDetails.setPassword(usUserMaster.getPassword());
        userDetails.setEnabled(usUserMaster.isActive());
        userDetails.setAccountNonExpired(usUserMaster.isActive());
        userDetails.setCredentialsNonExpired(usUserMaster.isActive());
        userDetails.setAccountNonLocked(usUserMaster.isActive());

        List<String> permissions = userDetails.getPermissions();
        usUserMaster.getUsUserRoleEntityList().stream().forEach(
                usUserRoleEntity -> {
                    usUserRoleEntity.getUsRoleEntity().getUsRolePermissionEntityList().stream().forEach(
                            usRolePermissionEntity -> permissions.add(usRolePermissionEntity.getUsPermissionEntity().getPermissionCode())
                    );
                });
        return Mono.just(userDetails);
    };
}

위 코드에서 사용자 정보를 조회하기 위해 준비해둔 UsUserMasterRepository 를 주입받는다. 이 레포지토리를 이용하여 사용자 정보 권한(퍼미션)정보를 조회해서 CustomUserDetails를 생성 후 반환한다.

위의 코드 중 일부인 아래 코드는 사용자에게 설정되어있는 권한을 설정해 주는 부분이다. 이렇게 설정된 권한은 JWT 토큰 생성시 토큰에 그대로 담겨진다. 이 정보는 나중에 이 사용자가 특정 메소드를 호출할 수 있는 권한을 체크하기 위해 활용된다. 이에대한 DB 구조는 아래 하단의 DB 스키마를 참조하자. RBAC을 기초로하여 구성되었다. 이론적인 부분은 아래의 참고자료중 첫 번째 링크를 확인하자.

 List<String> permissions = userDetails.getPermissions();
        usUserMaster.getUsUserRoleEntityList().stream().forEach(
                usUserRoleEntity -> {
                    usUserRoleEntity.getUsRoleEntity().getUsRolePermissionEntityList().stream().forEach(
                            usRolePermissionEntity -> permissions.add(usRolePermissionEntity.getUsPermissionEntity().getPermissionCode())
                    );
                });

PBKDF2Encoder (암복호화 클래스)

PBKDF2Encoder는 ReactiveAuthenticationManager가 ReactiveUserDetailsService를 이용해서 조회한 사용자 정보와 클라이언트로부터 전달 받은 평문 암호를 비교해 주는 것과 평문 암호를 암호화 해주기 위한 클래스다. 여기서는 클라이언트의 요청인 평문 암호를 암호화 한다음 ReactiveUserDetailsService에서 조회한 사용자의 암호와 비교하는데 활용된다. 소스는 다음과 같다.

package com.sthwin.hulk.component;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.util.Base64;

@Component
public class PBKDF2Encoder implements PasswordEncoder {
    @Value("${security.password.encoder.secret}")
    private String secret;

    @Value("${security.password.encoder.iteration}")
    private int iteration;

    @Value("${security.password.encoder.keylength}")
    private int keylength;

    @Override
    public String encode(CharSequence rawPassword) {
        try {
            byte[] result = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512")
                    .generateSecret(new PBEKeySpec(rawPassword.toString().toCharArray(), secret.getBytes(), iteration, keylength))
                    .getEncoded();
            return Base64.getEncoder().encodeToString(result);
        } catch (NoSuchAlgorithmException | InvalidKeySpecException ex) {
            throw new RuntimeException(ex);
        }
    }

    /**
     * rawPassword를 encode 메소드에 전달하여 인코딩 후, 비교한다.
     *
     * @param rawPassword     클라리언트로부터 전달 받을 패스워드.
     * @param encodedPassword DB에서 가져온 패스워드.
     * @return
     */
    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        return encode(rawPassword).equals(encodedPassword);
    }
}

현재까지의 설정으로 인해 요청은 다음과 같이 전달된다.

요청 흐름의 순서는 다음과 같다.

  1. 클라이언트의 요청이 JwtTokenAuthenticationFilter를 통과한다.
    • 이 필터 클래스에서는 요청의 헤더 값에 Bearer 가 있는지 확인하고, 있을 경우 JwtTokenProvider를 통해 Authentication을 생성 후, ReactiveSecurityContextHolder.withAuthentication()을 통해 Authentication을 리액터 컨텍스트에 등록한다. 이후 부터는 동일한 리액터 컨텍스트 내에서는ReactiveSecurityContextHolder.getContext()를 사용하여 인증 정보를 얻을 수 있다.
  2. SecurityWebFilterChain 생성시 인증필터 부분(.formLogin().disable(), .httpBasic().disable())은 모두 비활성화 시켰지 때문에 이 필터는 지나친다.
  3. JwtTokenAuthenticationFilter를 통과한 요청은 AuthorizationWebFiler 로 전달되고 이 필터를 통해 인증이 필요한 uri 인지와 인증은 되어 있는지, 퀀한은 있는지가 체크되고 조건에 부합하지 않을 경우 AccessDeniedException 을 반환한다.

사실 위의 프로세스에서는 ReactiveAuthenticationManager가 사용되지 않는다. JWT를 이용한 인증/인가를 하는 애플리케이션 이므로, 인증은 토큰통해 이루어지기 때문이다. 여기서는 ReactiveAuthenticationManager는 로그인 프로세스시 진행시 활용된다. 다음은 Authentication 클래스를 JWT 로 변환 시키고 변환된 JWT의 유효성 체크와 JWT를 다시 Authentication 클래스로 변화시켜주는 JwtTokenProvider 에 대해 알아본다.

JwtTokenProvider

JwtTokenProvider는 Authentication 클래스를 JWT 로 만들어주고, 변환된 JWT를 다시 Authentication 클래스로 변화시켜주는 기능을 한다. 이 클래스는 로그인 서비스와 JwtTokenAuthenticationFilter 클래스에서 활용된다. jwt.io 에서 제공해주는 라이브러리를 사용하며, 자세한 설명들은 여러 사이트에서 확인이 가능하다.

package com.sthwin.hulk.component;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

@Slf4j
@Component
public class JwtTokenProvider {

    private static final String AUTHORITIES_KEY = "permissions";

    @Value("${security.jjwt.secret}")
    private String secret;

    @Value("${security.jjwt.expiration}")
    private String expirationTime;

    private SecretKey secretKey;

    @PostConstruct
    public void init() {
        var secret = Base64.getEncoder().encodeToString(this.secret.getBytes());
        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    public String createToken(Authentication authentication) {
        String username = authentication.getName();
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Claims claims = Jwts.claims().setSubject(username);
        if (authorities != null) {
            claims.put(AUTHORITIES_KEY
                    , authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(",")));
        }

        Long expirationTimeLong = Long.parseLong(expirationTime);
        final Date createdDate = new Date();
        final Date expirationDate = new Date(createdDate.getTime() + expirationTimeLong);
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(username)
                .setIssuedAt(createdDate)
                .setExpiration(expirationDate)
                .signWith(secretKey)
                .compact();
    }


    public Authentication getAuthentication(String token) {
        Claims claims = Jwts.parserBuilder().setSigningKey(this.secretKey).build().parseClaimsJws(token).getBody();

        Object authoritiesClaim = claims.get(AUTHORITIES_KEY);

        Collection<? extends GrantedAuthority> authorities = authoritiesClaim == null ? AuthorityUtils.NO_AUTHORITIES
                : AuthorityUtils.commaSeparatedStringToAuthorityList(authoritiesClaim.toString());

        User principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

    public boolean validateToken(String token) {
        try {
            Jws<Claims> claims = Jwts
                    .parserBuilder().setSigningKey(this.secretKey).build()
                    .parseClaimsJws(token);
            //  parseClaimsJws will check expiration date. No need do here.
            log.info("expiration date: {}", claims.getBody().getExpiration());
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            log.info("Invalid JWT token: {}", e.getMessage());
            log.trace("Invalid JWT token trace.", e);
        }
        return false;
    }
}

JwtTokenAuthenticationFilter

JwtTokenAuthenticationFilter는 WebFilter를 상속하는 필터클래스로 위에서 SecurityWebFilterChain 빌드시 인증필터 앞에 놓이도록 설정했다. 이 필터 클래스는 전달받은 요청의 헤더에 Bearer 값이 있으면 해당 값을 꺼내서 JwtTokenProvider를 통하여 유효성을 체크하고, Authentication 클래스를 만들어서 ReactiveSecurityContextHolder.withAuthentication()메소드를 사용해서 컨텍스트에 등록해 주는 역할을 한다. 이후, AuthorizationWebFiler 필터 통과시 AuthorizationWebFiler는 컨택스트로부터 Authentication 클래스 정보를 얻은 후, 인증과 인가를 체크한다. 소스는 다음과 같다.

package com.sthwin.hulk.filter;

import com.sthwin.hulk.component.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;

/**
 * JWT 토큰이 http request header에 있는지 확인하기 위한 필터
 * 토근이 있을 경우, 유효성 체크 후, 토큰을 이용하여 인증 정보를 만든다.
 */

@Slf4j
@RequiredArgsConstructor
public class JwtTokenAuthenticationFilter implements WebFilter {
    public static final String HEADER_PREFIX = "Bearer ";

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        String token = resolveToken(exchange.getRequest());
        if(StringUtils.hasText(token) && this.jwtTokenProvider.validateToken(token)) {
            Authentication authentication = this.jwtTokenProvider.getAuthentication(token);
            return chain.filter(exchange)
                    .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication));
        }
        return chain.filter(exchange);
    }

		// 요청으로부터 JWT 토큰을 얻는다.
    private String resolveToken(ServerHttpRequest request) {
        String bearerToken = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(HEADER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

로그인 서비스

LoginService 클래스는 사용자가 로그인 시도시 전달받은 아이디와 패스워드를 확인 후, JWT 토큰을 발급해주는 서비스다. ReactiveAuthenticationManager 클래스를 통해 인증 확인 후, JWT를 생성해서 응답으로 반환한다.

package com.sthwin.hulk.service;

import com.sthwin.hulk.component.JwtTokenProvider;
import com.sthwin.hulk.dto.LoginRequestDto;
import com.sthwin.hulk.dto.LoginResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
@Service
@RequiredArgsConstructor
public class LoginService {
    private final JwtTokenProvider jwtTokenProvider;
    private final ReactiveAuthenticationManager authenticationManager;


    public Mono<LoginResponseDto> login(LoginRequestDto loginRequestDto) {
        if (loginRequestDto.getPassword() == null || loginRequestDto.getUsername() == null)
            return Mono.error(new ServerWebInputException("User Input Invalidation"));

        Authentication authentication = new UsernamePasswordAuthenticationToken(loginRequestDto.getUsername(),
                loginRequestDto.getPassword());
        return authenticationManager.authenticate(authentication)
                .map(jwtTokenProvider::createToken)
                .map(token -> LoginResponseDto.builder().token(token).build());
    }
}

Permission(권한 또는 특권 또는 privilege) 체크

이 애플리케이션은 클라이언트의 요청으로 호출되는 모든 메소드에 대해 권한이 있는지를 체크하도록 되어 있다. 따라서 호출되는 모든 메소드는 아이디를 가지고 있어야 하고 사용자는 해당 메소드를 호출하기 위해 해당 메소드 아이디가 매핑된 권한을 가지고 있어야 한다. JWT 에는 사용자에게 할당되어 있는 모든 퍼미션들이 포함되어 있으며 이 정보들은 모두 DB에 정의 되어 있다. 퍼미션의 이름 규칙은 핸들러 클래스내의 각 메소드 이름을 기준으로 "핸들러클래스이름:메소드이름"으로 구성된다. 예를들어 클래스가 다음과 같을 때 퍼미션의 이름은 "UserHandler:getUser" 이 된다.

public class UserHandler {
    @PreAuthorize("hasPermission('UserHandler:getUser', '')")
    public Mono<ServerResponse> getUser(ServerRequest request) {
        long personId = Long.valueOf(request.pathVariable("id"));
        return userService.getUser(personId)
                .flatMap(userMaster -> ok().contentType(MediaType.APPLICATION_JSON_UTF8).bodyValue(userMaster))
                .switchIfEmpty(ServerResponse.notFound().build());
    }
}

이 애플리케이션에서는 권한을 체크하기 위핸 핸들러 클래스나 메소드에 @PreAuthorize 을 사용한다. 이 애노테이션이 우리의 목정에 맞게 동작하도록 하기 위해서는 @EnableReactiveMethodSecurity이 선언과 함께 PermissionEvaluator를 DefaultMethodSecurityExpressionHandler에 등록 되어야 한다 (위의 ReactiveSecurityConfig 클래스 참조).

@Bean
@DependsOn({"methodSecurityExpressionHandler"})
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
                                                     JwtTokenProvider jwtTokenProvider,
                                                     ReactiveAuthenticationManager reactiveAuthenticationManager) {
    DefaultMethodSecurityExpressionHandler defaultWebSecurityExpressionHandler = this.applicationContext.getBean(DefaultMethodSecurityExpressionHandler.class);
    defaultWebSecurityExpressionHandler.setPermissionEvaluator(myPermissionEvaluator());
		... 생략 ...
}

@EnableReactiveMethodSecurity 애노테이션을 선언하면 DefaultMethodSecurityExpressionHandler가 자동으로 생성된다. 여기에 우리가 생성한 PermissionEvaluator 를 등록하면 @PreAuthorize("hasPermission()")가 선언 되었을 때 우리가 정의한 메소드가 호출되도록 할 수 있다.

추가로 @DependsOn({"methodSecurityExpressionHandler"}) 애노테이션이 선언되어 있는데 securityWebFilterChain 메소드가 DefaultMethodSecurityExpressionHandler 클래스가 생성된 이후에 호출되도록 보장하기 위해서이다. 일단 예제에서는 등록시점을 securityWebFilterChain 메소드 생성시로 잡고 있어서 그런것으로 보이는 다른 부분에서 설정해 줄 수 있는지는 확인하지 못했다.

우리가 생성하는 PermissionEvaluator 소스는 다음과 같다.

@Bean
public PermissionEvaluator myPermissionEvaluator() {
    return new PermissionEvaluator() {
        @Override
        public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
            if(authentication.getAuthorities().stream()
                    .filter(grantedAuthority -> grantedAuthority.getAuthority().equals(targetDomainObject))
                    .count() > 0)
                return true;
            return false;
        }

        @Override
        public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
            return false;
        }
    };
}

이제 메소드 위에 @PreAuthorize("hasPermission()") 애노테이션이 호출 될 때마다 우리가 정의한 PermissionEvaluator 클래스의 hasPermission 메소드가 호출된다. hasPermission 메소드가 false 를 반환하면 403(Forbidden)을 반환하게 된다. 활용 예제는 다음과 같다.

@Component
@RequiredArgsConstructor
public class UserHandler {

    private final UserService userService;
    private final ExternalCallService externalCallService;

    @PreAuthorize("hasPermission('UserHandler:getUser', '')")
    public Mono<ServerResponse> getUser(ServerRequest request) {
        long personId = Long.valueOf(request.pathVariable("id"));
        return userService.getUser(personId)
                .flatMap(userMaster -> ok().contentType(MediaType.APPLICATION_JSON_UTF8).bodyValue(userMaster))
                .switchIfEmpty(ServerResponse.notFound().build());
    }

    @PreAuthorize("hasPermission('UserHandler:callService', '')")
    public Mono<ServerResponse> callService(ServerRequest request) {
        return externalCallService.callTest()
                .flatMap(val -> ok().contentType(MediaType.APPLICATION_JSON_UTF8).bodyValue(val))
                .switchIfEmpty(ServerResponse.notFound().build());
    }
}

RBAC 용으로 사용한 데이터 베이스 스키마

사용된 의존성 정보(그래들 기준)

plugins {
    id 'org.springframework.boot' version '2.4.4'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'com.sthwin'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '15'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

ext {
    set('springCloudVersion', "2020.0.2")
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'org.postgresql:postgresql'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'io.projectreactor:reactor-test'
    testImplementation 'org.springframework.security:spring-security-test'

    // 써드파티
    implementation "io.jsonwebtoken:jjwt-api:0.11.1"
    runtimeOnly "io.jsonwebtoken:jjwt-impl:0.11.1"
    runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.11.1"
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}

test {
    useJUnitPlatform()
}

참고자료:

'SpringFramework' 카테고리의 다른 글

스프링 WebClient 동시에 호출하기  (0) 2020.05.24
@Bean  (0) 2011.05.31
@Resource, @Autowired 사용시기  (0) 2011.05.30
스프링에 대한 대표적인 오해중의 하나  (0) 2011.05.28
<property> 요소 사용방법  (0) 2011.05.28