Backend/Spring

[Spring Security] 인가 프로세스 DB 연동 웹 계층 구현

김세진 2023. 2. 19. 18:18
반응형

 

 

 

 

스프링 시큐리티 인가 개요


DB 연동 권한 관리가 필요한 이유

Spring Security에서 사용자가 재정의하여 사용하는 SecurityConfig 설정 클래스를 통해 선언적 방식으로 인가 처리를 수행할 수 있다. 단, 이 방법은 하드코딩으로 진행되기 때문에 동적 권한 관리가 필요한 범용 서비스에서는 권장되지 않는다. 따라서 DB와 연동하여 자원 및 권한을 설정하고 제어함으로 동적 권한 관리가 가능하도록 해야 한다.

 

설정 클래스 소스에서 권한 관련 코드 모두 제거

  • ex) antMatcher(“/user”).hasRole(USER)
 

관리자 시스템 구축

  • 회원 관리 – 권한 부여
  • 권한 관리 – 권한 생성삭제
  • 자원 관리 – 자원 생성삭제수정권한 매핑

 

권한 계층 구현

  • URL – Url 요청 시 인가 처리
  • Method – 메소드 호출 시 인가 처리
    • Method
    • Pointcut (AOP)

 

 

관리자 시스템 - 권한 도메인, 서비스, 리포지토리 구성


도메인 관계도

Acount, Role, Resources 각각의 Entity가 1:N 관계로 구성되어 있다.

 

테이블 관계도

각 Entity를 1:N으로 연결하기 위한 참조 테이블이 위와 같이 존재한다.

 

 

 

웹 기반 인가처리 DB 연동 - 주요 아키텍처 이해


스프링 시큐리티의 인가 처리

FilterSecurityInterceptor 필터가 AccessDecisionManager에게 인가 처리를 요청할 때 위와 같은 정보를 전달하여 처리한다.

 

 

AccessDecisionManager

 

 인가 처리를 담당하는 핵심 인터페이스로, FilterSecurityInterceptor로부터 사용자의 인증 정보, 요청 정보, 권한 정보를 전달 받아 인가 처리를 수행한다. AccessDecisionManager 클래스에 등록된 여러 개의 Voter를 통해 인가 처리를 수행하는데, 각 Voter 객체들이 주어진 정보를 통해 투표를 진행하며, 3개의 구현체 중 선택된 구현체의 전략에 따라 자원 접근 허용 여부가 최종 결정된다.

 

  • AffirmativeBased : Voter 중 하나라도 ACCESS_GRANTED를 반환할 경우 허용 (default 전략)
  • ConsensusBased : 다수결에 따름
  • UnanimousBased : 모든 Voter가 ACCESS_GRANTED를 반환해야 허용 (하나라도 ACCESS_DENIED가 있으면 예외 처리)

 

public interface AccessDecisionManager {

   // 주어진 인증(Authentication) 정보와 요청된 자원(Object)에 대한 권한(ConfigAttribute) 목록을 비교하여 접근 가능 여부 최종 결정
   void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
         throws AccessDeniedException, InsufficientAuthenticationException;

   // 이 클래스가 지원하는 타입인지 확인
   boolean supports(ConfigAttribute attribute);

   boolean supports(Class<?> clazz);

}

* 요청 정보가 Object타입인 이유: URL, Method 방식에 따라 FilterInvocation, MethodInvocation 타입으로 나누어져 받기 때문

 

AccessDecisionVoter

public interface AccessDecisionVoter<S> {

	int ACCESS_GRANTED = 1;

	int ACCESS_ABSTAIN = 0;

	int ACCESS_DENIED = -1;
    
    boolean supports(ConfigAttribute attribute);
    
    boolean supports(Class<?> clazz);
    
    int vote(Authentication authentication, S object, Collection<ConfigAttribute> attributes);
}

 

ConsensusBased 일부

private boolean allowIfEqualGrantedDeniedDecisions = true;

public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
			throws AccessDeniedException {
		int grant = 0;
		int deny = 0;
		for (AccessDecisionVoter voter : getDecisionVoters()) {
			int result = voter.vote(authentication, object, configAttributes);
			switch (result) {
			case AccessDecisionVoter.ACCESS_GRANTED:
				grant++;
				break;
			case AccessDecisionVoter.ACCESS_DENIED:
				deny++;
				break;
			default:
				break;
			}
		}
		if (grant > deny) {
			return;
		}
		if (deny > grant) {
			throw new AccessDeniedException(
					this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
		}
		// 동률이고, 하나 이상 찬성표가 있을 때
		if ((grant == deny) && (grant != 0)) {
			// defualt: true
			if (this.allowIfEqualGrantedDeniedDecisions) {
				return;
			}
			throw new AccessDeniedException(
					this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
		}
		// To get this far, every AccessDecisionVoter abstained
		// 부모 클래스인 AbstractAccessDecisionManager에 존재하는 메서드
		checkAllowIfAllAbstainDecisions();
	}

 

  • grant가 deny보다 높은 경우 접근 허용
  • grant가 deny보다 낮은 경우 예외 처리
  • grant와 deny가 동률이고, 하나 이상 찬성표가 있는 경우 allowIfEqualGrantedDeniedDecisions 설정에 따라간다. 기본값이 true이므로 접근 허용
  • 모든 표가 기권표(ACCESS_ABSTAIN)인 경우 부모인 추상 클래스 AbstractAccessDecisionManager의 메서드를 호출한다. 해당 메서드는 allowIfAllAbstainDecisions 설정에 따라 접근 허용, 혹은 예외 처리한다. 기본값이 false이므로 예외 처리

 

 

주요 아키텍처

AbstractSecurityInterceptor의 구현체인 FilterSecurityInterceptor에서 인증 정보, 요청 정보, 권한 정보를 추출하여 AccessDecisionManager로 정보를 넘겨 인가 처리를 수행한다.

 

  • SecurityContext 내부에 있는 인증 정보(Authentication)를 가져온다.
  • FilterInvocation 클래스에서 request 요청 정보를 얻는다.
  • Security 설정 클래스에 작성했던 hasRole("USER")와 같은 텍스트를 파싱하여 권한 정보를 얻는다.
    • Spring Security가 초기화될 때, 자원 정보("/user")와 권한 정보(hasRole("User"))를 각각 Key, Value로 Map 객체를 생성하여 저장해둠

 

AbstractSecurityInterceptor 일부 (FilterSecurityInterceptor의 부모 클래스)

protected InterceptorStatusToken beforeInvocation(Object object) { 
		/* ..생략.. */
        
		// 권한 정보 추출
		Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);
		
		/* ..생략.. */
        
		// 인증 정보 추출
		Authentication authenticated = authenticateIfRequired();

		/* ..생략.. */
        
		// 요청, 인증, 권한 정보를 AccessDecisionManager에 위임하여 인가 처리 수행
		// object = FilterInvocation
		attemptAuthorization(object, attributes, authenticated);
        
		/* ..생략.. */
        
	}

 

* ConfigAttribute는 보안 속성을 의미하는 인터페이스로, 그 구현체로는 SecurityConfig가 있다. 아래와 같은 방식으로 생성하여 사용하는게 일반적이다.

ConfigAttribute attribute = new SecurityConfig("ROLE_ADMIN");

 

SecurityMetadataSource

SecurityMetadataSource라는 최상위 인터페이스에서 3가지의 메서드가 존재하는데. getAttributes() 라는 메서드를 Override 하여 DB를 이용한 인가 처리를 수행할 수 있게 된다.

 

  • URL: DefaultFilterInvocationSecurityMetadataSource와 그 구현체인 ExpressionBasedFilterInvocationSecurityMetadataSource 두 클래스를 참조하여 FilterInvocationSecurityMetadataSource라는 인터페이스를 직접 구현하여 DB에 존재하는 url 권한 정보 추출이 가능하도록 한다. (위 두 클래스는 사용하지 않음)
  • Method: 클래스를 직접 생성하진 않고 세 어노테이션을 제공하는 클래스와 MapBasedMethodSecurityMetadataSource 클래스를 활용하여 인가 처리를진행한다.

 

 

 

웹 기반 인가처리 DB 연동 - FilterInvocationSecurityMetadataSource (1)


개요

 

FilterInvocationSecurityMetadataSource 라는 인터페이스를 UrlFilterInvocationSecurityMetadataSource 라는 이름으로 구현하여 아래와 같은 기능을 수행할 것이다.

 

  • 사용자가 접근하고자 하는 Url 자원에 대한 권한 정보 추출
  • AccessDecisionManager 에게 전달하여 인가처리 수행
  • DB 로부터 자원 및 권한 정보를 매핑하여 맵으로 관리
  • 사용자의 매 요청마다 요청정보에 매핑된 권한 정보 확인
 

Flow

 

  1. 유저가 GET 방식으로 /admin 요청을 보냄
  2. FilterSecurityInterceptor에서 해당 요청을 FilterInvocationSecurityMetadataSource에 전달하여 권한 정보 조회를 요청함. FilterInvocationSecurityMetadataSource는 DB에서 자원정보와 권한정보를 가져와 RequestMap 객체에 담아 보관하고 있음
  3. /admin을 키값으로 하는 권한 정보를 추출함
  4. 만약 해당 url에 대한 권한 목록이 아예 존재하지 않는다면 Pass 처리하여 인가 처리를 하지 않고, 권한 목록이 존재한다면 AccessDecisionManager인증 객체, 요청 정보, 권한 정보를 넘겨 인가 처리를 위임함

 

 

 

 

웹 기반 인가처리 DB 연동 - FilterInvocationSecurityMetadataSource (2)


Url 방식 – Map 기반 DB 연동

UrlResourcesMapFactoryBean: DB로 부터 얻은 권한/자원 정보를 ResurceMap 을 Bean으로 생성해서 UrlFilterInvocationSecurityMetadataSource 에 전달하는 역할을 함

 

 

UrlFilterInvocationSecurityMetadataSource

@RequiredArgsConstructor
public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

    // 요청에 대한 권한 순서 유지를 위해 일반 HashMap이 아닌 LinkedHashMap을 사용함
    private final LinkedHashMap<RequestMatcher, List<ConfigAttribute>> requestMap;

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

        HttpServletRequest request = ((FilterInvocation) object).getRequest();

        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;
    }

    // 아래 두 메서드는 DefaultFilterInvocationSecurityMetadataSource 참조한 것
    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        Set<ConfigAttribute> allAttributes = new HashSet<>();
        this.requestMap.values().forEach(allAttributes::addAll);
        return allAttributes;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }
}

 

UrlResourcesMapFactoryBean

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

    private SecurityResourceService securityResourceService;
    private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> resourceMap;

    public void setSecurityResourceService(SecurityResourceService securityResourceService) {
        this.securityResourceService = securityResourceService;
    }


    @Override
    public LinkedHashMap<RequestMatcher, List<ConfigAttribute>> getObject() throws Exception {
        if (resourceMap == null) {
            init();
        }

        return resourceMap;
    }

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

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

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

 

SecurityResourceService

@Service
@RequiredArgsConstructor
public class SecurityResourceService {

    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;
    }
}

 

SecurityConfig에 추가

@Bean
public AccessDecisionManager affirmativeBased() {
    AffirmativeBased affirmativeBased = new AffirmativeBased(getAccessDecisionVoters());
    return affirmativeBased;
}

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

@Bean
public FilterSecurityInterceptor customFilterSecurityInterceptor() throws Exception {
    FilterSecurityInterceptor filterSecurityInterceptor = new FilterSecurityInterceptor();

    filterSecurityInterceptor.setSecurityMetadataSource(urlFilterInvocationMetadataSource());
    filterSecurityInterceptor.setAccessDecisionManager(affirmativeBased());
    return filterSecurityInterceptor;
}

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

@Bean
public UrlResourcesMapFactoryBean urlResourcesMapFactoryBean() {
    UrlResourcesMapFactoryBean urlResourcesMapFactoryBean = new UrlResourcesMapFactoryBean();
    urlResourcesMapFactoryBean.setSecurityResourceService(securityResourceService);

    return urlResourcesMapFactoryBean;
}
// filterChain에 추가
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    
    http.addFilterBefore(customFilterSecurityInterceptor(), FilterSecurityInterceptor.class);
    
}

 

 

 

웹 기반 인가처리 실시간 반영하기


Url 방식 – 인가처리 실시간 반영하기

권한/자원 정보는 늘 고정된 것이 아닌 동적으로 변화하므로, 상태가 변할 때마다 실시간 반영이 필요하다.

따라서 권한/자원 정보가 업데이트 되는 시점에 DB에서 권한 정보를 불러와ResourceMap에 새로 채워주면 된다.

 

 

UrlFilterInvocationSecurityMetadataSource 에 추가

private final SecurityResourceService securityResourceService;

/* ... */

public void reload() {
    LinkedHashMap<RequestMatcher, List<ConfigAttribute>> reloadedMap = securityResourceService.getResourceList();
    Iterator<Map.Entry<RequestMatcher, List<ConfigAttribute>>> iterator = reloadedMap.entrySet().stream().iterator();

    requestMap.clear();

    while (iterator.hasNext()) {
        Map.Entry<RequestMatcher, List<ConfigAttribute>> entry = iterator.next();
        requestMap.put(entry.getKey(), entry.getValue());
    }
}

 

 

 

인가처리 허용 필터 - PermitAllFilter 구현


Url 방식 - PermitAllFilter 구현

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

 

FilterSecurityInterceptor를 상속받아 PermitAllFilter를 구현하여 인증 및 권한심사가 필요 없는 자원에 한해서는 부모 클래스인 AbstractSecurityInterceptor로 넘어가 인가 처리를 진행하지 않고 바로 통과할 수 있도록 할 수 있다.

 

 

PermitAllFilter

public class PermitAllFilter extends FilterSecurityInterceptor {

    private static final String FILTER_APPLIED = "__spring_security_filterSecurityInterceptor_filterApplied";

    private List<RequestMatcher> permitAllRequestMatcher = new ArrayList<>();

    public PermitAllFilter(String... permitAllResources) {
        for (String resource : permitAllResources) {
            permitAllRequestMatcher.add(new AntPathRequestMatcher(resource));
        }
    }

    @Override
    protected InterceptorStatusToken beforeInvocation(Object object) {
        HttpServletRequest request = ((FilterInvocation) object).getRequest();
        for (RequestMatcher requestMatcher : permitAllRequestMatcher) {
            if (requestMatcher.matches(request)) {
                // permit (인가 처리 생략)
                return null;
            }
        }

        // permitAll 에 있지 않은 자원이므로 부모 클래스로 넘어가 인가 처리 진행
        return super.beforeInvocation(object);
    }

    public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException {
        if (isApplied(filterInvocation) && super.isObserveOncePerRequest()) {
            filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
            return;
        }
        if (filterInvocation.getRequest() != null && super.isObserveOncePerRequest()) {
            filterInvocation.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
        }
        // super.beforeInvocation 로 넘어가지 않고 해당 클래스의 beforeInvocation 에서 permitAll 검사
        InterceptorStatusToken token = beforeInvocation(filterInvocation);
        try {
            filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
        } finally {
            super.finallyInvocation(token);
        }
        super.afterInvocation(token, null);
    }

    private boolean isApplied(FilterInvocation filterInvocation) {
        return (filterInvocation.getRequest() != null)
                && (filterInvocation.getRequest().getAttribute(FILTER_APPLIED) != null);
    }
}

 

SecurityConfig 수정

// permitAll 할 자원 목록, 추후 DB 연동하여 가져오게끔 수정하면 됨
private String[] permitAllResources = {"/", "/login", "user/login/**"};

/* ... */

@Bean
public PermitAllFilter customFilterSecurityInterceptor() throws Exception {
    PermitAllFilter permitAllFilter = new PermitAllFilter(permitAllResources);

    permitAllFilter.setSecurityMetadataSource(urlFilterInvocationMetadataSource());
    permitAllFilter.setAccessDecisionManager(affirmativeBased());
    return permitAllFilter;
}

 

 

 

계층 권한 적용하기- RoleHierarchy


Url 방식 - 계층 권한 적용하기

RoleHierarchy 

  • 상위 계층 Role은 하위 계층 Role의 자원에 접근 가능하도록 하는 클래스
  • ROLE_ADMIN > ROLE_MANAGER > ROLE_USER 일 경우 ROLE_ADMIN 만 있으면 하위 ROLE 의 권한을 모두 포함한다.

 

RoleHierarchyVoter

  • RoleHierarchy 를 생성자로 받으며 이 클래스에서 설정한 규칙이 적용되어 심사한다.
 

Flow

  1. DB에서 계층 권한 목록을 가져온다.
  2. 위 그림과 같은 방식으로 포매팅하여 RoleHierarchy에 권한 계층 정보를 저장한다.
    • 권한 목록을 위 그림과 같은 방식의 문자열로 포매팅하는 RoleHierarchyService 인터페이스를 만들고 구현하는 방식으로 진행
    • 이 작업이 진행될 시점은 직접 지정해야 한다. 일반적으로 처음 서버가 기동될 때, 그리고 권한 계층 정보가 바뀔 때 두 가지 시점에 작업이 진행되어야 할 것이다.
  3. AccessDecisionManager의 구현체 중 하나인 RoleHierarchyVoter를 통해 인가 결정을 하게 되는데, RoleHierarchy에 등록된 정보를 기준으로 권한 계층을 적용한 인가 처리를 하게 된다.

 

RoleHierarchyServiceImpl

@Service
@RequiredArgsConstructor
public class RoleHierarchyServiceImpl implements RoleHierarchyService {

    final private RoleHierarchyRepository roleHierarchyRepository;

    @Transactional
    @Override
    public String findAllHierarchy() {

        // DB에서 Role 계층 정보를 불러옴
        List<RoleHierarchy> rolesHierarchy = roleHierarchyRepository.findAll();

        StringBuilder concatedRoles = new StringBuilder();

        for (RoleHierarchy model : rolesHierarchy) {
            if (model.getParentName() != null) {
                concatedRoles.append(model.getParentName().getChildName());
                concatedRoles.append(" > ");
                concatedRoles.append(model.getChildName());
                concatedRoles.append("\n");
            }
        }
        return concatedRoles.toString();

    }
}

 

 

SecurityInitializer

@Component
@RequiredArgsConstructor
public class SecurityInitializer implements ApplicationRunner {

    final private RoleHierarchyService roleHierarchyService;
    final private RoleHierarchyImpl roleHierarchy;

    @Override
    public void run(ApplicationArguments args) {
        // Security가 기동될 때 DB에 저장된 계층 정보를 가져와 반영함
        String allHierarchy = roleHierarchyService.findAllHierarchy();
        roleHierarchy.setHierarchy(allHierarchy);
    }
}

 

SecurityConfig

// AccessDecisionManager에 전달될 Voter 목록 반환
private List<AccessDecisionVoter<?>> getAccessDecisionVoters() {
    List<AccessDecisionVoter<? extends Object>> accessDecisionVoters = new ArrayList<>();
    accessDecisionVoters.add(roleVoter());

    return accessDecisionVoters;
}

// RoleHierarchyVoter 생성
@Bean
public AccessDecisionVoter<? extends Object> roleVoter() {

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

// Role Hierarchy를 구성
@Bean
public RoleHierarchyImpl roleHierarchy() {

    RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
    return roleHierarchy;
}

 

 

 

 

 

아이피 접속 제한하기 - CustomIpAddressVoter


Url 방식 – 아이피 접속 제한하기

 

IpAddressVoter

  • AccessDecisionVoter 인터페이스를 구현해서 만들어야 한다.
  • 허용된 IP만 접근이 가능하도록 심의하며, Voter 목록 중 가장 먼저 심사한다.
  • 허용된 IP일 경우 ACCESS_GRANTED가 아닌 ACCESS_ABSTAIN을 반환하여 이후 심사가 계속 진행되도록 한다.
  • 허용되지 않은 IP일 경우 ACCESS_DENIED를 반환하지 않고, 즉시 AccessDeniedException 예외를 발생하여 자원 접근을 최종 거부한다.
    → ACCESS_DENIED를 반환할 경우 다른 Voter에 대해서 계속 심사를 진행하기 때문
public class IpAddressVoter implements AccessDecisionVoter {

    @Override
    public int vote(Authentication authentication, Object object, Collection collection) {
        WebAuthenticationDetails details = (WebAuthenticationDetails) authentication;
        String remoteAddress = details.getRemoteAddress();

        // 허용된 IP 리스트를 불러옴
        List<String> accessIpList = securityResourceService.getAccessIpList();

        int result = ACCESS_DENIED;

        for (String ipAddress : accessIpList) {
            if (remoteAddress.equals(ipAddress)) {
                // GRANTED가 아닌 ABSTAIN을 반환하여 어떤 전략이어도 이어서 검증을 수행하도록 함
                return ACCESS_ABSTAIN;
            }
        }

        if (result == ACCESS_DENIED) {
            // 허용되지 않은 IP일 경우 즉시 예외처리
            throw new AccessDeniedException("Invalid IpAddress");
        }

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

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

 

 

반응형