본문 바로가기
Dev/SpringBoot

14. [Spring Boot] URL별 접근권한 DB에서 가져와서 처리하기(1)

by VIPeveloper 2020. 6. 17.
반응형

1. 서론

- 이번 포스팅에서는 URL이 조회될 때마다 해당 URL의 접근권한을 확인해서 접속 가능, 불가능을 처리하는 방법을 포스팅해보겠습니다.

- 과정이 조금 많이 복잡해서 이해하기 힘들었는데, 수정하거나 고칠 점이 있다면 알려주시면 감사하겠습니다.

 

2. 본론

DB 구조

- 우선 ROLE - ROLE_RESOURCES - RESOURCES, 이렇게 세 가지의 테이블이 존재합니다.

- 이는 ROLE - RESOURCES가 원래 M:N 매핑되어있어야 하지만, 1:N + N:1 관계로 쪼개 놓기 위함입니다.

- DB 데이터는 미리 설정하여 입력해놓았습니다.

- H2 DB는 간단한 CRUD를 DB내부에서 간단하게 처리할 수 있도록 돕습니다.

 

Role.java

- 도메인 설계를 시작하겠습니다. 다음 3가지 테이블의 도메인을 설계해줍니다.

import lombok.*;

import javax.persistence.*;
import java.io.Serializable;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;

@Entity
@Getter
@ToString(exclude = {"roleResources"})
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(of = "id")
public class Role implements Serializable {

    @Id
    @GeneratedValue
    @Column(name = "role_id")
    private Long id;

    @Column(name = "role_name")
    private String roleName;

    @Column(name = "role_desc")
    private String roleDesc;

    @OneToMany(mappedBy = "resources",cascade = CascadeType.ALL)
    private Set<RoleResources> roleResources = new LinkedHashSet<>();
}

RoleResources.java

import lombok.*;

import javax.persistence.*;
import java.io.Serializable;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)  // 생성자를 다른 계층에서 함부로 생성하지 못하도록 막는다.
public class RoleResources implements Serializable {

    @Id
    @GeneratedValue
    @Column(name = "role_resource_id")
    private Long id;

    // 1:N에서 N쪽을 담당하고 있읍니다.
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "resource_id")
    private Resources resources;

    // 1:N에서 N쪽을 담당하고 있읍니다.
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "role_id")
    private Role role;
}

Resources.java

import lombok.*;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.*;
import java.io.Serializable;
import java.util.HashSet;
import java.util.Set;

@Entity
@Getter
@ToString(exclude = {"roleResources"})    // toString시 roleResources은 빼고 나타내줌
@EntityListeners(value = { AuditingEntityListener.class })  // 생성일자, 수정일자, 생성자, 수정자 컬럼 넣어줌
@EqualsAndHashCode(of = "id")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Resources implements Serializable {

    @Id
    @GeneratedValue
    @Column(name = "resource_id")
    private Long id;

    @Column(name = "resource_name")
    private String resourceName;

    @Column(name = "http_method")
    private String httpMethod;

    @Column(name = "order_num")
    private int orderNum;

    @Column(name = "resource_type")
    private String resourceType;

    @OneToMany(mappedBy = "role", cascade = CascadeType.ALL)
    private Set<RoleResources> roleResources = new HashSet<>();

}

 

다음은 코드의 핵심 부분인 SecurityConfig부분입니다. 이 코드는 그동안 제가 포스팅해오면서 누적된 코드가 포함되어있습니다. 

 

SecurityConfig.java

import com.ktnet.security.factory.UrlResourcesMapFactoryBean;
import com.ktnet.security.handler.*;
import com.ktnet.security.metadatasource.UrlFilterInvocationSecurityMetadataSource;
import com.ktnet.service.SecurityResourceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDecisionManager;
import org.springframework.security.access.AccessDecisionVoter;
import org.springframework.security.access.vote.AffirmativeBased;
import org.springframework.security.access.vote.RoleVoter;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;

import java.util.Arrays;
import java.util.List;

@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private SecurityResourceService securityResourceService;


    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        String password = passwordEncoder().encode("123");
        auth
            .inMemoryAuthentication()
                .withUser("user").password(password).roles("USER")
            .and()
                .withUser("manager").password(password).roles("USER", "MANAGER")
            .and()
                .withUser("admin").password(password).roles("USER", "MANAGER", "ADMIN");
        auth.userDetailsService(userDetailsService);
    }

    @Override
    // js, css, image 설정은 보안 설정의 영향 밖에 있도록 만들어주는 설정.
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
//                .antMatchers("/loginUser","/login*","/users/new","/").permitAll()
//                .antMatchers("/users/user").hasRole("USER")
//                .antMatchers("/users/manager").hasRole("MANAGER")
//                .antMatchers("/users/admin").hasRole("ADMIN")
                .anyRequest().authenticated()
            .and()
                .formLogin()
                .loginPage("/users/login")           // template 커스터마이징
                .defaultSuccessUrl("/")                 // 성공시 어디로갈까?
                .failureUrl("/users/login")           // 실패시 어디로갈까?
                .successHandler(new CustomSuccessHandler())
                .failureHandler(new CustomFailureHandler())
                .permitAll()       // 로그인 경로는 모두 허용해줘야 한다.
            .and()
                .exceptionHandling()
                .accessDeniedHandler(accessDeniedHandler())     // 인증 거부 관련 처리
            .and()
                .logout()
                .logoutUrl("/logout")
                .logoutSuccessUrl("/")
                .addLogoutHandler(new CustomLogoutHandler())
                .logoutSuccessHandler(new CustomLogoutSuccessHandler())
                .deleteCookies("remember-me")
            .and()
                .addFilterBefore(customFilterSecurityInterceptor(),FilterSecurityInterceptor.class);
    }


    private AccessDeniedHandler accessDeniedHandler() {
        CustomAccessDeniedHandler accessDeniedHandler = new CustomAccessDeniedHandler();
        accessDeniedHandler.setErrorPage("/users/denied");
        return accessDeniedHandler;
    }


    @Bean
    // BCryptPasswordEncoder는 Spring Security에서 제공하는 비밀번호 암호화 객체입니다.
    // Service에서 비밀번호를 암호화할 수 있도록 Bean으로 등록합니다.
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }


    @Bean
    public FilterSecurityInterceptor customFilterSecurityInterceptor() throws Exception {
        FilterSecurityInterceptor filterSecurityInterceptor = new FilterSecurityInterceptor();
        filterSecurityInterceptor.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource());   // 권한정보 셋팅
        filterSecurityInterceptor.setAccessDecisionManager(affirmativeBased());
        filterSecurityInterceptor.setAuthenticationManager(authenticationManagerBean());    // 인증매니저
        return filterSecurityInterceptor;
    }


    private AccessDecisionManager affirmativeBased() {
        AffirmativeBased affirmativeBased = new AffirmativeBased(getAccessDecisionVoters());
        return affirmativeBased;
    }


    private List<AccessDecisionVoter<? extends Object>> getAccessDecisionVoters() {
        return Arrays.asList(new RoleVoter());
    }


    @Bean
    public FilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource() throws Exception {
        return new UrlFilterInvocationSecurityMetadataSource(urlResourcesMapFactoryBean().getObject());
    }

    private UrlResourcesMapFactoryBean urlResourcesMapFactoryBean() {
        UrlResourcesMapFactoryBean urlResourcesMapFactoryBean = new UrlResourcesMapFactoryBean();
        urlResourcesMapFactoryBean.setSecurityResourceService(securityResourceService);
        return urlResourcesMapFactoryBean;
    }

    // 인증 매니저
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

 

- 이전 포스팅까지는 아래의 정적 코드를 사용하여 정적으로 박아놓았지만, 이제는 DB를 조율해서 동적으로 자유롭게 추가할 수 있는 가능성(?)이 주어졌습니다.

//                .antMatchers("/loginUser","/login*","/users/new","/").permitAll()
//                .antMatchers("/users/user").hasRole("USER")
//                .antMatchers("/users/manager").hasRole("MANAGER")
//                .antMatchers("/users/admin").hasRole("ADMIN")

 

 

- 주의 깊게 보아야 할 부분은 이 부분입니다. FilterSecurityInterceptor클래스를 이용하여 매 순간 인증 권한이 있는지를 검사해줍니다.

.and().addFilterBefore(customFilterSecurityInterceptor(),FilterSecurityInterceptor.class);

 

 

커스텀 된 customFilterSecurityInterceptor() 메서드는 @Bean 등록 이후 아래의 코드와 같이 꾸며줍니다.

    @Bean
    public FilterSecurityInterceptor customFilterSecurityInterceptor() throws Exception {
        FilterSecurityInterceptor filterSecurityInterceptor = new FilterSecurityInterceptor();
        filterSecurityInterceptor.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource());   // 권한정보 셋팅
        filterSecurityInterceptor.setAccessDecisionManager(affirmativeBased());
        filterSecurityInterceptor.setAuthenticationManager(authenticationManagerBean());    // 인증매니저
        return filterSecurityInterceptor;
    }

 

- 이중 권한 정보를 세팅하는 과정은 매우 어렵기 때문에, 포스팅 정리하는데도 매우 힘들었습니다.

filterSecurityInterceptor.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource());   // 권한정보 셋팅

1. filterSecurityInterceptor는 set을 합니다. 그 set이 뭐냐면 setSecurityMetadataSource를 해서 새로운 보안 메타데이터 소스를 설정하는데, urlFilterInvocationSecurityMetadataSource()라는 메서드로 하겠다는 뜻입니다.

 

 

2. urlFilterInvocationSecurityMetadataSource()는 아래의 @Bean으로 등록된 메서드를 실행시킵니다.

    @Bean
    public FilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource() throws Exception {
        return new UrlFilterInvocationSecurityMetadataSource(urlResourcesMapFactoryBean().getObject());
    }

놀랍게도 반환 값이 UrlFilterInvocationSecurityMetadataSource입니다. urlResourceMapFactoryBean()이라는 메서드에서 getObject()라는 놈을 파라미터로 받아 뭘 하려는 것 같습니다.

 

 

3. 결론

urlResourceMapFactoryBean()은 뭘 하는 놈인지 2편에서 자세하게 알아보겠습니다.

 

 

반응형