본문 바로가기
SpringSecurity

인가

by 이상한나라의개발자 2023. 12. 12.

스프링 시큐리티는 인증(authentication) 인가(authorization)를 위한 프레임워크로, 웹 보안을 담당합니다.

인가란 이미 인증된 사용자에게 특정 리소스에 대한 접근 권한을 부여하거나 제한하는 과정 입니다.

 

1. 수동적 인가 방식

 

아래 소스 코드에서 처럼 특정 리소스에 대한 권한을 수동으로 매핑합니다. 

 

  • URL 방식
@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .authorizeRequests()
        .antMatchers("/shop/login", "/shop/users/**").permitAll()
	    
        .antMatchers("/shop/mypage").hasRole("USER")
        
        // 구체적인 경로가 먼저오고 큰 범위의 경로가 뒤에 오도록 작성한다.
        
        .antMatchers("/shop/admin/pay").access("hasRole('ADMIN')")
	    .antMatchers("/shop/admin/**").access("hasRole('ADMIN') or hasRole('SYS')")
        .anyRequest().authenticated()

}

 

* 주의사항 - 설정 시 구체적인 경로가 먼저오고 그것 보다 큰 범위의 경로가 뒤에 오도록 해야 합니다.

 

 

  • 메소드 방식

@PerAuthorize 어노테이션은 메소드를 호출하기 전 에 권한을 체크하며, 조건식, 메소드 매개변수, 반환값 등 다양한 정보에 접근하여 복잡한 보안 조건을 정의할 수 있습니다.  또한, 다른 어노테이션과의 결합을 지원합니다.

 

@Secure는 특정 권한을 가진 사용자만 접근 할수 있도록 제한합니다.

 

@Configuration
@EnableWebSecurity
//@EnableGlobalMethodSecurity(securedEnabled = true)
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig {
    // 설정 작업
}


@Service
public class BookService {

    @PreAuthorize("hasRole('ROLE_ADMIN')")
    public void addBook(Book book) {
        // 로직
    }
	
    // @PreAuthorize("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
    // @PreAuthorize("hasRole('ROLE_ADMIN') and #param == authentication.principal.username")
    @PreAuthorize("#username == authentication.principal.username or hasRole('ROLE_ADMIN')")
    public Book getBook(String username, Long bookId) {
        // 로직
    }
}

// @Secured({"ROLE_USER", "ROLE_ADMIN"})
@Secured("ROLE_ADMIN")
public void someMethod() {
    ...
}

 

 

2. DB 연동 방식

 

DB와 연동하여 자원 및 권한을 설정하고 제어함으로 동적 권한 관리가 가능하도록 한다.

  • 설정 클래스 소스에서 수동적 설정 방식을 모두 제거한다. 
  • http.antMatchers("/user").access("hasRole('USER')") <- 제거 
  • 사용자(인증정보) /user(요청정보) 에 접근하기 위해서는 ROLE_USER(권한정보) 권한이 필요하다

 

인가 1

 

 

 

자원에 설정된 권한 정보를 추출 하도록 구현

 

 

 

전체 흐름

 

 

 

URL 방식 매핑을 지원하는 FilterInvocationSecurityMetadataSource

 

아래의 작동 흐름을 보면 사용자가 요청한 리소스를 FilterSecurityInterceptor 에서 받아서 FilterInvocationSecurityMetadataSource 인터페이스를 구현 권한 정보를 조회 하며 최종적으로 접근 결정 관리자 ( AccessDecisionManager) 가 처리하기 된다.

 

 

 

 

 

UrlFilterInvocationSecurityMetadataSource 구현

 

 

public class UrlFilterInvocationSecurityMetadatsSource implements FilterInvocationSecurityMetadataSource {

    private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> requestMap = new LinkedHashMap<>();

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {

        // 사용자가 어떤 리소스를 요청했나
        HttpServletRequest request = ((FilterInvocation) object).getRequest();

		// 이 부분을 db 에서 조회하여 매핑해주면 됩니다. 
        requestMap.put(new AntPathRequestMatcher("/mypage"), Arrays.asList(new SecurityConfig("ROLE_USER")));

        if(requestMap != null){
            for(Map.Entry<RequestMatcher, List<ConfigAttribute>> entry : requestMap.entrySet()){
                RequestMatcher matcher = entry.getKey();
                if(matcher.matches(request)){
                    return entry.getValue(); // 권한 정보 리턴
                }
            }
        }

        return null;
    }

 

 

SecurityConfig 소스 설정

 

여기서 중요한 부분은 FilterSecurityInterceptor  customFilterSecurityInterceptor과 configure 설정 부분에 

and().addFilterBefore(customFilterSecurityInterceptor(), FilterSecurityInterceptor.class) 인데요. 우리가 설정한 방식을 적용하게 됩니다. 그럼 UrlFilterInvocationSecurityMetadataSource 를 호출하여 실제 리소스 권한 정보를 매핑하여 전달하게 됩니다.이렇게 되면 수동으로 매핑한 antMatchers 부분은 작동을 하지 않게 됩니다. 

 

 

@Configuration
@EnableWebSecurity
@Slf4j
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final FormWebAuthenticationDetailsSource formWebAuthenticationDetailsSource;
    private final AuthenticationSuccessHandler formAuthenticationSuccessHandler;
    private final AuthenticationFailureHandler formAuthenticationFailureHandler;
    private final CustomUserDetailService userDetailService;

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authenticationProvider());
        auth.authenticationProvider(ajaxAuthenticationProvider());
    }

    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(final HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
//                .antMatchers("/mypage").hasRole("USER")
//                .antMatchers("/messages").hasRole("MANAGER")
//                .antMatchers("/config").hasRole("ADMIN")
                .antMatchers("/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login")
                .loginProcessingUrl("/login_proc")
                .authenticationDetailsSource(formWebAuthenticationDetailsSource)
                .successHandler(formAuthenticationSuccessHandler)
                .failureHandler(formAuthenticationFailureHandler)
                .permitAll()
        .and()
                .exceptionHandling()
//                .authenticationEntryPoint(new AjaxLoginAuthenticationEntryPoint())
                .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))
                .accessDeniedPage("/denied")
                .accessDeniedHandler(accessDeniedHandler())
        .and()
                .addFilterBefore(customFilterSecurityInterceptor(), FilterSecurityInterceptor.class)
        ;

        http.csrf().disable();

        customConfigurer(http);
    }

    private void customConfigurer(HttpSecurity http) throws Exception {
        http
                .apply(new AjaxLoginConfigurer<>())
                .successHandlerAjax(ajaxAuthenticationSuccessHandler())
                .failureHandlerAjax(ajaxAuthenticationFailureHandler())
                .loginProcessingUrl("/api/login")
                .setAuthenticationManager(authenticationManagerBean());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public AuthenticationProvider authenticationProvider(){
        return new FormAuthenticationProvider(userDetailService, passwordEncoder());
    }

    @Bean
    public AuthenticationProvider ajaxAuthenticationProvider(){
        return new AjaxAuthenticationProvider(passwordEncoder());
    }

    @Bean
    public AjaxAuthenticationSuccessHandler ajaxAuthenticationSuccessHandler(){
        return new AjaxAuthenticationSuccessHandler();
    }

    @Bean
    public AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler(){
        return new AjaxAuthenticationFailureHandler();
    }

    public AccessDeniedHandler accessDeniedHandler() {
        FormAccessDeniedHandler commonAccessDeniedHandler = new FormAccessDeniedHandler();
        commonAccessDeniedHandler.setErrorPage("/denied");
        return commonAccessDeniedHandler;
    }

    // 인가 처리자
    @Bean
    public FilterSecurityInterceptor customFilterSecurityInterceptor() throws Exception {

        FilterSecurityInterceptor filterSecurityInterceptor = new FilterSecurityInterceptor();
        filterSecurityInterceptor.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource());
        filterSecurityInterceptor.setAccessDecisionManager(affirmativeBased());
        filterSecurityInterceptor.setAuthenticationManager(authenticationManagerBean()); // 인증관리자
        return filterSecurityInterceptor;
    }

    // URL 방식 인가 조회
    @Bean
    public FilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource() {
        return new UrlFilterInvocationSecurityMetadatsSource();
    }

    // 인가 결정자
    private AccessDecisionManager affirmativeBased() {
        return new AffirmativeBased(getAccessDecistionVoters());
    }

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

 

 

위 부분 까지 했다면 한단계 더 나아가 실제 DBD에서 조회 하는 방법을 알아봅시다. 

 

db 데이터는 resources key 와 다수의 role 로 key, value 형식으로 저장합니다.

아래 소스는 초기에 권한을 세팅하여 우리가 만든 UrlFilterInvocationSecurityMetadatsSource 에 최종 전달하게 됩니다.

먼저 UrlResourcesMapFactoryBean -> SecurityResourcesService ( 권한 정보 조회 서비스 )  를 아래와 같이 개발합니다.

 

@RequiredArgsConstructor
public class UrlResourcesMapFactoryBean implements FactoryBean<LinkedHashMap<RequestMatcher, List<ConfigAttribute>>> {

    private final SecurityResourcesService securityResourcesService;
    private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> resourceMap;
    
    @Override
    public LinkedHashMap<RequestMatcher, List<ConfigAttribute>> getObject() throws Exception {

        if ( resourceMap == null ) {
            init();
        }
        return resourceMap;
    }

    @Override
    public Class<?> getObjectType() {
        return LinkedHashMap.class;
    }

    @Override
    public boolean isSingleton() {
        return true;
    }

    private void init() {
        resourceMap = securityResourcesService.getResourceList();
    }
}


@Service
@RequiredArgsConstructor
public class SecurityResourcesService {

    private final ResourcesRepository resourcesRepository;

    public LinkedHashMap<RequestMatcher, List<ConfigAttribute>> getResourceList() {
        LinkedHashMap<RequestMatcher, List<ConfigAttribute>> result = new LinkedHashMap<>();
        List<Resources> resourcesList = resourcesRepository.findAllResources();

        resourcesList.forEach(re -> {
            List<ConfigAttribute> configAttributeList = new ArrayList<>();
            re.getRoleSet().forEach(role -> {
                configAttributeList.add(new SecurityConfig(role.getRoleName()));
                result.put(new AntPathRequestMatcher(re.getResourceName()), configAttributeList);
            });

        });

        return result;
    }
}

 

 

이렇게 해서 권한 정보를 가져오게 되는데요. 설정 시 구체적인 경로가 먼저오고 그것 보다 큰 범위의 경로가 뒤에 오도록 해야 하므로 권한 정보를 담을때는 LinkHashMap으로 순서를 보장하도록 합니다.

 

마지막으로 SecurityConfig에서 설정을 하면 됩니다

 

 

실시간으로 권한 반영

 

    public void reload() {
        LinkedHashMap<RequestMatcher, List<ConfigAttribute>> reloadMap = securityResourceService.getResourceList();
        Iterator<Map.Entry<RequestMatcher, List<ConfigAttribute>>> iterator = reloadMap.entrySet().iterator();
        requestMap.clear();
        while( iterator.hasNext()) {
            Map.Entry<RequestMatcher, List<ConfigAttribute>> next = iterator.next();
            requestMap.put(next.getKey(), next.getValue());
        }
    }

 

 

UrlFilterInvocationSecurityMetadatsSource 에서 실제 권한 정보가 변경 되었을 경우 위 코드를 호출하여 권한 정보를 다시 리로드 하게 됩니다.

 

이때 권한 변경 로직이 있는 소스에서 UrlFilterInvocationSecurityMetadatsSource를 주입받아 urlFilterInvocationSecurityMetadatsSource.reload() 를 호출하면 권한이 실시간으로 반영 되게 됩니다.

 

 

PermitAllFlter

 

인증 및 권한심사를 할 필요가 없는 자원 ex ( "/", /home", "/index", "/login"..) 등을 설정해서 바로 리소스 접근이 가능하게 하는 필터

 

동작원리

 

 

내부 동작원리를 보면 실제 리소스가 권한이 없다면 List<ConfigAttribute> 가 null 이면 권한 심사없이 바로 통과 되게끔 시큐리티는 작동을 하게됩니다.

 

SecurityConfig에서 permitAll() 로 처리해도 되지만 불필요한 인가 처리 과정을 없애기 위해 응용동작을 구현 해보겠습니다.

응용 동작은 인가처리 심사 전에 FilterSecurityInterceptor을 상속받는 PermitAllFilter 클래스를 만들어 미리 심사가 필요없는 자원에 대해서 null 로 리턴하게 하는 동작 원리 입니다.

 

 

public class PermitAllFilter extends FilterSecurityInterceptor {
    private static final String FILTER_APPLIED = "__spring_security_filterSecurityInterceptor_filterApplied";
    private boolean observeOncePerRequest = true;
	
    // 심사하지 않는 리소스 항목
    private List<RequestMatcher> permitAllRequestMatchers =  new ArrayList<>();
	
    public PermitAllFilter(String...permitAllResources){

        for(String resource : permitAllResources){
            permitAllRequestMatchers.add(new AntPathRequestMatcher(resource));
        }
    }

    @Override
    protected InterceptorStatusToken beforeInvocation(Object object) {
    	// 처리 과정
        boolean permitAll = false;
        HttpServletRequest request = ((FilterInvocation) object).getRequest();
        for(RequestMatcher requestMatcher : permitAllRequestMatchers){
            if(requestMatcher.matches(request)){
                permitAll = true;
                break;
            }
        }
		// null로 리턴 
        if(permitAll){
            return null;
        }

        return super.beforeInvocation(object);
    }

    public void invoke(FilterInvocation fi) throws IOException, ServletException {
        if ((fi.getRequest() != null)
                && (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
                && observeOncePerRequest) {
            // filter already applied to this request and user wants us to observe
            // once-per-request handling, so don't re-do security checking
            fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
        }
        else {
            // first time this request being called, so perform security checking
            if (fi.getRequest() != null && observeOncePerRequest) {
                fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
            }

            InterceptorStatusToken token = beforeInvocation(fi);

            try {
                fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
            }
            finally {
                super.finallyInvocation(token);
            }

            super.afterInvocation(token, null);
        }
    }
}



--- SecurityConfig.java

private String[] permitAllResources = {"/", "/login", "/user/login/**"};


// FilterSecurityInterceptor -> PermitAllFilter로 적용
@Bean
public PermitAllFilter customFilterSecurityInterceptor() throws Exception {

    PermitAllFilter permitAllFilter = new PermitAllFilter(permitAllResources);
    permitAllFilter.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource());
    permitAllFilter.setAccessDecisionManager(affirmativeBased());
    permitAllFilter.setAuthenticationManager(authenticationManagerBean());
    return permitAllFilter;
}

 

 

 

계층 권한 적용하기

 

계층권한 적용

 

RoleHierarchy 상위 계층 Role는 하위 계층 Role의 자원에 접근을 가능하게 합니다.

ROLE_ADMIN > ROLE_MANAGER > ROLE_USER 일 경우 ROLE_ADMIN 권한 만 있으면 하위 ROLE의 권한을 모두 포함 합니다.

 

RoleHierarchyVoter는 RoleHierarchy를 생성자로 받으며 이 클래스에서 설정한 규칙이 적용되어 심하합니다.

 

먼저, 위 이미지 처럼 권한 계층 구조를 DB에서 조회하여 똑같이 세팅하여야 하며, 그전에

DB ROLE_HIERARCHY 테이블에서 부모 자식의 구조를 만들어야 합니다.

 

ROLE_ADMIN > ROLE_MANAGER

ROLE_MANAGER > ROLE_USER

 

@Service
public class RoleHierarchyServiceImpl implements RoleHierarchyService {

    @Autowired
    private RoleHierarchyRepository roleHierarchyRepository;

    @Transactional
    @Override
    public String findAllHierarchy() {

        List<RoleHierarchy> rolesHierarchy = roleHierarchyRepository.findAll();

        Iterator<RoleHierarchy> itr = rolesHierarchy.iterator();
        StringBuilder concatedRoles = new StringBuilder();
        while (itr.hasNext()) {
            RoleHierarchy roleHierarchy = itr.next();
            if (roleHierarchy.getParentName() != null) {
                concatedRoles.append(roleHierarchy.getParentName().getChildName());
                concatedRoles.append(" > ");
                concatedRoles.append(roleHierarchy.getChildName());
                concatedRoles.append("\n");
            }
        }
        return concatedRoles.toString();

    }
}


-- 서버가 초기화 될때 해당 하이라키 구조를 가져 옵니다.
-- 그리고 RoleHierachy에 조회한 구조를 set 합니다.

@Component
public class SecurityInitializer implements ApplicationRunner {

    @Autowired
    private RoleHierarchyService roleHierarchyService;

    @Autowired
    private RoleHierarchyImpl roleHierarchy;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        String allHierarchy = roleHierarchyService.findAllHierarchy();
        roleHierarchy.setHierarchy(allHierarchy);
    }
}

-- SecurityConfig 설정
 private List<AccessDecisionVoter<?>> getAccessDecisionVoters() {

        List<AccessDecisionVoter<? extends Object>> accessDecisionVoters = new ArrayList<>();
        accessDecisionVoters.add(roleVoter());
        return accessDecisionVoters;
    }

    @Bean
    public AccessDecisionVoter<? extends Object> roleVoter() {

        RoleHierarchyVoter roleHierarchyVoter = new RoleHierarchyVoter(roleHierarchy());
        return roleHierarchyVoter;
    }

    @Bean
    public RoleHierarchyImpl roleHierarchy() {
        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
        return roleHierarchy;
    }

 

 

 

아이피 접속 제한하기

 

흐름

 

 

  • 특정한 IP 만 접근이 가능하도록 심의하는 Voter 추가
  • Voter 중에서 가장 먼저 심사하도록 하여 허용된 IP 일 경우에만 최종 승인 및 거부 결정을 하도록 한다.
  • 허용된 IP 이면 ACCESS_GRANTED 가 아닌 ACCESS_ABSTAIN을 리턴하여 추가 심의를 계속 진행 하도록 한다.
  • 허용된 IP 가 아니면 ACCESS_DENIED를 리턴하지 않고 즉지 예외를 발생하여 최종 자원 접근을 거부한다.

 

	-- SecurityConfig 설정

private List<AccessDecisionVoter<?>> getAccessDecisionVoters() {

    List<AccessDecisionVoter<? extends Object>> accessDecisionVoters = new ArrayList<>();
    accessDecisionVoters.add(new IpAddressVoter(securityResourceService));
    accessDecisionVoters.add(roleVoter());

    return accessDecisionVoters;
}
    
-- IP Address Vote
public class IpAddressVoter implements AccessDecisionVoter<Object> {

private SecurityResourceService securityResourceService;

public IpAddressVoter(SecurityResourceService securityResourceService) {

    this.securityResourceService = securityResourceService;
}

@Override
public boolean supports(ConfigAttribute attribute) {
    return true;
}

@Override
public boolean supports(Class<?> clazz) {
    return true;
}

@Override
public int vote(Authentication authentication, Object object, Collection<ConfigAttribute> attributes) {

    WebAuthenticationDetails details = (WebAuthenticationDetails)authentication.getDetails();
    String remoteAddress = details.getRemoteAddress();

    List<String> accessIpList = securityResourceService.getAccessIpList();

    int result = ACCESS_DENIED;

    for(String ipAddress : accessIpList){
        if(remoteAddress.equals(ipAddress)){
            return ACCESS_ABSTAIN;
        }
    }

    if(result == ACCESS_DENIED){
        throw new AccessDeniedException("Invalid IpAddress");
    }

    return result;
}


-- SecurityResourceService 에 추가
@Service
@RequiredArgsConstructor
public class SecurityResourceService {

	private final ResourcesRepository resourcesRepository;
    private final AccessIpRepository accessIpRepository;

    public LinkedHashMap<RequestMatcher, List<ConfigAttribute>> getResourceList(){

        LinkedHashMap<RequestMatcher, List<ConfigAttribute>> result = new LinkedHashMap<>();
        List<Resources> resourcesList = resourcesRepository.findAllResources();
        resourcesList.forEach(re ->{
            List<ConfigAttribute> configAttributeList =  new ArrayList<>();
            re.getRoleSet().forEach(role -> {
                configAttributeList.add(new SecurityConfig(role.getRoleName()));
            });
            result.put(new AntPathRequestMatcher(re.getResourceName()),configAttributeList);

        });
        return result;
    }


    public List<String> getAccessIpList() {
        List<String> accessIpList = accessIpRepository.findAll().stream().map(accessIp -> accessIp.getIpAddress()).collect(Collectors.toList());
        return accessIpList;
    }
}

'SpringSecurity' 카테고리의 다른 글

JWT  (1) 2023.12.12
인증 FormLogin  (0) 2023.12.12