이번에 security의 동적 권한?에 대해 알아보자.
권한 관리를 정적으로 하지 않고 db에서 권한을 관리하는 거다.
예전에 시큐리티를 처음 했을때 낑낑거리던 그때가 생각난다.
시큐리티가 좋긴 하지만 어렵다. 아무튼 한번 보자. 물론 내가 틀릴 수도 있다.

user-erd

대충 설계는 위와 같다.(hierarchy 구조는 일단 뺏다. 하드코딩)
Authorities는 매핑 테이블이다. User와 role을 매핑 시킨다. 한 user가 여러개의 롤을 가질수 있으며 롤 역시 여러개의 유저에 할당 할 수 있다.
RoleResouce도 마찬가지다. Resouces는 실제 url 정보를 가지고 있다.
실제 이 예제에선 url을 등록 하지만 패턴으로 해도 상관 없을 듯 하다.
실제 패턴이 낫다. url을 한개씩 관리하면 관리가 힘들지 않을까?

spring boot와 jpa를 기준으로 만들었다.
일단 소스를 보자

...

@Bean
public FilterSecurityInterceptor filterSecurityInterceptor() {
  FilterSecurityInterceptor filterSecurityInterceptor = new FilterSecurityInterceptor();
  filterSecurityInterceptor.setAuthenticationManager(authenticationManager);
  filterSecurityInterceptor.setSecurityMetadataSource(filterInvocationSecurityMetadataSource);
  filterSecurityInterceptor.setAccessDecisionManager(affirmativeBased());
  return filterSecurityInterceptor;
}

@Bean
public AffirmativeBased affirmativeBased() {
  List<AccessDecisionVoter<? extends Object>> accessDecisionVoters = new ArrayList<>();
  accessDecisionVoters.add(roleVoter());
  AffirmativeBased affirmativeBased = new AffirmativeBased(accessDecisionVoters);
  return affirmativeBased;
}

@Bean
public RoleHierarchyVoter roleVoter() {
  RoleHierarchyVoter roleHierarchyVoter = new RoleHierarchyVoter(roleHierarchy());
  roleHierarchyVoter.setRolePrefix("ROLE_");
  return roleHierarchyVoter;
}

//RoleHierarchy 설정
@Bean
public RoleHierarchy roleHierarchy() {
  RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
  roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER");
  return roleHierarchy;
}

...

기본적인 셋팅은 제외하고 그외의 중요한 빈들만 보자
이 예제는 hierarchy 구조를 설정 하였으나 하드코딩한 예제이다. 나중에 기회가 된다면 다시 한번 살펴보겠다.
hierarchy 구조를 설정 하고 싶지 않다면 RoleVoter을 빈으로 등록 하면 된다.
RoleHierarchyVoter은 RoleVoter을 상속받고 있긴 하다.
여기서 중요한건 FilterSecurityInterceptor 클래스 이다.
여기선 위에 보다시피 3가지 객체를 넣는다.
첫번째는 authenticationManager 로그인한 인증정보를 담기 위함이라고 하는데 이건 잘 모르겠다.
두번째는 filterInvocationSecurityMetadataSource url의 대상 정보이다. 실제 url이 권한과 일치 하는지 확인하는거다.
마지막으로 AccessDecisionManager는 위와 두개를 이용해서 실제 이 사용자가 허용이 되야 하는지 거부가 되어야 하는지 판단해준다.

DefaultFilterInvocationSecurityMetadataSource를 확장해서 사용하거나 빈으로 등록할 때 생성자에 requestMap 를 담아줘도 된다.

필자는 filterInvocationSecurityMetadataSource를 구현 했다.
해당 url이 어떤 권한을 가지고 있는지 확인해주어야 하기 때문이다.
filterInvocationSecurityMetadataSource 의 구현체를 살펴 보자

@Slf4j
public class FilterMetadataSource implements FilterInvocationSecurityMetadataSource, InitializingBean {

  @Autowired
  private ResourceMetaService resourceMetaService;

  @Autowired
  private CacheManager cacheManager;


  @Override
  public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
    FilterInvocation fi = (FilterInvocation) object;
    String url = fi.getRequestUrl();

    List<AuthoritiesDto> userRoleDto = cacheManager.getAuthorities().get(url);
    if (userRoleDto == null) {
      return null;
    }
    List<String> roles = userRoleDto.stream().map(AuthoritiesDto::getRoleName).collect(Collectors.toList());

    String[] stockArr = new String[roles.size()];
    stockArr = roles.toArray(stockArr);

    return SecurityConfig.createList(stockArr);
  }

  @Override
  public Collection<ConfigAttribute> getAllConfigAttributes() {
    return null;
  }

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

  @Override
  public void afterPropertiesSet() throws Exception {
    resourceMetaService.findAllResources();
  }
}

url을 꺼내 와서 해당 url의 권한을 확인한다.
매번 db에 꺼내올 필요는 없어서 Cache 하고 있다. 최초 빈이 등록될때 url정보를 셋팅한다.
그리고 권한에 따른 url이 변경 될 때마다 이벤트를 줘야 한다.

@Slf4j
public class ResourceMetaServiceImpl implements ResourceMetaService {

  @Autowired
  private ResourceRepository resourceRepository;

  @Autowired
  private ApplicationContext applicationContext;

  @Override
  public void findAllResources() {
    List<AuthoritiesDto> authorities = resourceRepository.findAllAuthorities();

    authorities.stream().forEach(userRoleDto -> {
      log.info("role name {} ", userRoleDto.getRoleName());
      log.info("url {}", userRoleDto.getUrl());
    });
    applicationContext.publishEvent(new CacheEventMessage(this, authorities));
  }
}
public class CacheEventMessage extends ApplicationEvent {

  final List<AuthoritiesDto> authoritiesDto;

  public CacheEventMessage(Object source, final List<AuthoritiesDto> authoritiesDto) {
    super(source);
    this.authoritiesDto = authoritiesDto;
  }

  public List<AuthoritiesDto> getAuthoritiesDto() {
    return authoritiesDto;
  }
}

ApplicationEvent 는 한번 써봤다. 굳이 이렇게 안해도 된다.
applicationContext.publishEvent 를 하면 ApplicationListener를 구현하고 있는 구현체로 이벤트가 날라간다.

public class CacheManager implements ApplicationListener<CacheEventMessage> {

  private Map<String, List<AuthoritiesDto>> authorities;

  public Map<String, List<AuthoritiesDto>> getAuthorities() {
    return authorities;
  }

  public List<AuthoritiesDto> getAuthoritie(String key) {
    return authorities.get(key);
  }

  @Override
  public void onApplicationEvent(CacheEventMessage event) {
    authorities = event.getAuthoritiesDto()
      .stream().collect(groupingBy(AuthoritiesDto::getUrl, toList()));
  }
}

이벤트가 날라오면 다시 url정보를 셋팅한다.
설명이 이상하다. 아니 시큐리티가 어렵다.
소스를 보며 이것 저것 해보는게 더 빠를지도 모른다.
더 설명 하고 싶으나 내공이 부족하므로 이만 하겠다.ㅠㅠㅠ

해당 소스는 github에 올라가 있으니 그걸 보는게 더 낫겠다.


이슈보다는 하다가 어이 없던것
lombok이 엄청엄청 좋으나 가끔 이상한대서 어이없게 에러를 발생했다. 아니 lombok이 잘못한게 아니라 lombok에 모든걸 떠넘긴 탓일까
@Data는 toString 까지 구현해 준다. 그래서 문제가 됐다. jpa 에서 양방향이 되면 toString 호출시 스택오버플로우가 발생한다.
맞는말이긴 하지만 그래도 어이 없었다.. 그것만 조심하자. 이렇게 하나더 배우나요?

@ToString(exclude = {"제외시킬변수"})

exclude 를 사용해 제외 시키자!