> Hello World !!!

     

@syaku

Spring security Role Hierarchy - 역할 계층 구현하기

https://github.com/syakuis/role-hierarchy

Spring security 에서 효율적인 역할 (Role) 을 관리하기 위해 역할 계층(Role Hierarchy) 을 사용할 수 있다. 일반적으로 접근에 필요한 역할을 모두 사용자가 소유해야 한다.

즉 ROLE_ADMIN 관리자 역할이라고 역할 명을 정하더라도 이건 이름일뿐 제대로된 의미를 가지지 못한다.

ROLE_POST 가 필요한 접근 경로에는 ROLE_POST 를 무조건 사용자가 소유해야 한다. 만약 이런 역할들이 무수히 많다면 한 개의 계정에 무수히 많은 역할을 부여해야 한다. 역할을 관리해야 하는 기능에서 불편한 부분을 제공하게 된다.

그리고 역할을 그룹화 할 수 있지만 결국 이 또한 그룹이 많아지면 그만큼 그룹 역할을 부여해야 하는 불편함이 생긴다. 그래서 Spring Security 에서는 역할 계층 이라는 기능을 제공한다.

// 역할
ROLE_POST, ROLE_COMMENT, ROLE_FILE 

// 역할 상속
ROLE_MANAGER > ROLE_POST,
ROLE_MANAGER > ROLE_COMMENT,
ROLE_MANAGER > ROLE_FILE

ROLE_ADMIN > ROLE_MANAGER
  • ROLE_MANAGER 은 ROLE_POST, ROLE_COMMENT, ROLE_FILE 역할들이 소유한 모든 역할을 가진다.
  • ROLE_ADMIN 은 ROLE_MANAGER 이 소유한 역할을 모두 가진다.

개발 스펙

  • java 8
  • spring boot 2.1.2
  • gradle 5, junit 4, log4j2

구현

gradle.build

buildscript {
    apply plugin: 'java'

    ext {
        springBootVersion = "2.1.2.RELEASE"
        lombokVersion = "1.18.2"
    }

    repositories {
        maven {
            url "https://plugins.gradle.org/m2/"
        }
        mavenCentral()
        jcenter()
    }

    dependencies {
        classpath "org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}"
    }
}

apply plugin: "java"
apply plugin: "idea"
apply plugin: "org.springframework.boot"
apply plugin: "io.spring.dependency-management"

// intelliJ setting
idea {
    module {
        inheritOutputDirs = true
        outputDir = compileJava.destinationDir
        testOutputDir = compileTestJava.destinationDir
    }
}

repositories {
    jcenter()
    mavenCentral()
}

group 'org.syaku.security'
version '1.0.0'

sourceCompatibility = 1.8
targetCompatibility = 1.8

configurations {
    providedRuntime
    compile.exclude module: "spring-boot-starter-tomcat"
    compile.exclude module: "spring-boot-starter-logging"
}

dependencies {
    testCompile "junit:junit:4.12"

    implementation "org.projectlombok:lombok:${lombokVersion}"
    annotationProcessor "org.projectlombok:lombok:${lombokVersion}"
    testImplementation "org.projectlombok:lombok:${lombokVersion}"
    testAnnotationProcessor "org.projectlombok:lombok:${lombokVersion}"

    implementation "org.springframework.boot:spring-boot-starter-web"
    testImplementation "org.springframework.boot:spring-boot-starter-test"
    implementation "org.springframework.boot:spring-boot-starter-log4j2"

    implementation "org.springframework.boot:spring-boot-starter-jetty"

    implementation "org.springframework.boot:spring-boot-starter-security"
    testImplementation "org.springframework.security:spring-security-test"
}

wrapper {
    gradleVersion = '5.4.1'
}

설정

@Log4j2
@EnableWebSecurity
public class WebSecurityConfiguration {

		/*
			준비해야할 설정을 구성할때 사용하기 위한 공간
			준비가 필요없다면 SecurityConfiguration 클래스만 구현하면 된다.
		*/

    @Log4j2
    @Configuration
    static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
        @Bean
        public RoleHierarchy roleHierarchy() {
            RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();

            Map<String, List<String>> roleHierarchyMap = new HashMap<>();
            roleHierarchyMap.put("ROLE_ADMIN", Arrays.asList("ROLE_MANAGER"));
            roleHierarchyMap.put("ROLE_MANAGER", Arrays.asList("ROLE_POST", "ROLE_COMMENT", "ROLE_FILE"));
            roleHierarchyMap.put("ROLE_USER", Arrays.asList("ROLE_POST"));

            String roles = RoleHierarchyUtils.roleHierarchyFromMap(roleHierarchyMap);
            log.debug(roles);
            roleHierarchy.setHierarchy(roles);

            // 혹은 아래와 같이 작성할 수 있다.
            // roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_MANAGER\nROLE_MANAGER > ROLE_POST\nROLE_MANAGER > ROLE_COMMENT\nROLE_MANAGER > ROLE_FILE\nROLE_USER > ROLE_POST");
            return roleHierarchy;
        }

        @Bean
        public SecurityExpressionHandler<FilterInvocation> expressionHandler() {
            DefaultWebSecurityExpressionHandler webSecurityExpressionHandler = new DefaultWebSecurityExpressionHandler();
            webSecurityExpressionHandler.setRoleHierarchy(roleHierarchy());
            return webSecurityExpressionHandler;
        }

        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                .csrf()
                .and().authorizeRequests()
                // 인가 설정에서 순서도 중요하다. 먼저 판단하기 위해 상위에 배치해야 한다.
                .antMatchers(HttpMethod.POST, "/api/blog").hasRole("POST")
                .antMatchers("/api/blog/**").hasRole("MANAGER")
                .anyRequest()
                .authenticated()
                .expressionHandler(expressionHandler());
        }
    }
}

인증(Authentication) 과 인가(Authorization)가 있는 데, 에버랜드를 비유하여 설명하겠다.

에버랜드에 입장하기 위해 티켓이 있어야 하고 티켓 종류도 다양할 것이다. 티켓에 따란 내가 입장할 수 있는 입장 권한도 다를 것이다. 이런 것들을 판단하여 최종적으로 입장 허가증을 발급받는 데 이런 일련의 과정과 최종 입장 허가증을 발급받는 것 까지가 인증이다. 즉 인증에서는 인증 값 들과 접근할 수 있는 권한을 부여받게 된다.

입장 허가증이 있다고 해서 에버랜드 모든 곳을 들어갈 수 있는 것은 아니다. 티켓 종류에 따라 놀이기구나 관람 가능한 곳이 다르며 에버랜드를 관리 및 운영하는 스텝 일 수도 있는 것이다. 즉 어떠한 곳을 들어가기 위해 입장 가능 여부를 판다고 허가 해주는 것 까지를 인가라고 한다.

테스트

데모

@RestController
@RequestMapping("/api/blog")
public class BlogController {
    @GetMapping
    public void get() {
    }

    @PostMapping
    public void post() {

    }

    @PutMapping
    public void put() {

    }

    @DeleteMapping
    public void delete() {

    }
}

테스트

@Log4j2
@RunWith(SpringRunner.class)
@WebMvcTest(BlogController.class)
public class WebSecurityConfigurationTest {
    private String URL = "/api/blog";

    @Autowired private MockMvc mvc;

    @Test
    @WithMockUser(username = "admin", roles = "ADMIN")
    public void admin() throws Exception {
        mvc.perform(get(URL)).andExpect(status().isOk());
    }

    @Test
    @WithMockUser(username = "manager", roles = "MANAGER")
    public void manager() throws Exception {
        mvc.perform(put(URL).with(csrf())).andExpect(status().isOk());
    }

    @Test
    @WithMockUser(username = "user", roles = "USER")
    public void user() throws Exception {
        mvc.perform(post(URL).with(csrf())).andExpect(status().isOk());
    }
}