본문 바로가기
Spring/Spring Security

스프링 시큐리티의 권한(Authorization)

코동이 2021. 10. 15.

AOP는 관심사가 같은 코드들끼리 묶는 역할을 한다. 서로 섞지 않고 분리한다. IT초기에는 스파게티 코드들이 많았는데 권한, 트랜잭션 코드들이 비지니스 로직과 모두 묶여 있었기 때문이다. 좋은 설계를 위해 트랜잭션, 로그, 권한처리 등등을 비지니스 로직에서 분리하는 고민이 있었고 그 결과 AOP 기술이 탄생하였다. 공통의 관심사를 어떻게 분리하는가?

PointCut을 통해서 advice를 삽입한다. 코드를 서로 분리해두는데, 비지니스 로직의 적절한 곳에 권한, 로그 ,트랜잭션 커밋 등이 적용될 수 있도록 진입점을 가리켜 두는 것이다.

 

SecurityFilterChain당 1개의 AccessDecisionManager가 있으며 Method 권한 판정은 하나의 Global 위원회에서 처리한다. 스프링 시큐리티가 초기에 거대한 꿈을 가지고 만들어졌을 때! 권환 위원회라는 개념을 만들었다. (관리자 대신에 "위원회"라는 단어가 어울린다.)

 

 

 

Invocation에 체크할 대상이 담겨 있다. 그 내용이 ConfigAttribute에 들어가는데 AccessDecisionVoter가 vote를 통해 검사한다. 

 

AccessDecisionManager는 권환위원회이다.

AffirmativeBased : 긍정위원회는 1명만 통과해도 통과이다.

ConsensusBased : 다수결 위원회로 다수가 통과해야 통과한다.

UnanimouseBased : 만장일치 위원회로 모두가 통과해야 한다.

 

대부분 AffirmativeBased를 구현하고 대부분 해결이 가능하다.

 

 

 권한은 SecurityInterceptor에서 검사를 시작하는데, Invocation 대상이 AccessDecisinManager에게 넘겨져 평가를 받게 된다. decide()로 평가를 해서 GRANT 할지 DENY를 할지 정한다. AuthentiationProvidersupports()를 가지고 있는것처럼 AccessDecisionVotersupports() 메서드를 가지고 있다. 해당 ConfigAttribute를 가지고, 지금 조건을 너가 처리해줄 수 있니? 라고 질문을 한다. DecisionManager들이 DecisionVoter들에게 물어보고 가능하다면 vote() 하도록 한다.   그 결과를 취합해서 pass / AccessDeny를 구분한다. 

 

사실, accessDecisionManager에서 decide()만 구현해도 된다. vote()까지 꼭 구현할 필요는 없다. 기본적으로 스프링 시큐리티는 vote() 기반이기 때문에, @PreAuthorize 같은 기능들은 vote() 기반으로 사용하게끔 설계해주면 된다. 

 

 

 

 

 SecurtyInterceptor에 AccessDecisionManager가 있고, voter들을 데리고 접근 권한을 판정한다. 이 때, AuthenticationManager는 재검증을 수행할 수도 있다. RunAsManager로 임시 권한을 부여하는 장치를 둔다.

이 처리는 FilterSecurityInterceptor 혹은 MethodSecurityInterceptor에서도 동일하다. 권한 판정을 하기 위해서 ConfigAttribute가 필요한데, 이것을 모아놓은 map이 SecurityMetadataSource이다. 각각 다른 metadataSource를 가지고 있다.

 

* 어떤 방식으로 권한을 확인하는지 좀 더 자세히 살펴본다.

 

"ADMIN" 권한이 필요한 "/greeting" 경로 요청을 수행해본다.

 

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            .csrf().disable()
            .httpBasic()
            .and()
            .authorizeRequests(authority-> authority
                    .mvcMatchers("/greeting").hasRole("ADMIN")
                    .anyRequest().authenticated()
            );
}

 

 

AffirmativeBased 클래스에서 decide 메서드를 확인한다.

 

@Override
@SuppressWarnings({ "rawtypes", "unchecked" })
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
		throws AccessDeniedException {
	int deny = 0;
	for (AccessDecisionVoter voter : getDecisionVoters()) {
		int result = voter.vote(authentication, object, configAttributes);
		switch (result) {
		case AccessDecisionVoter.ACCESS_GRANTED:
			return;
		case AccessDecisionVoter.ACCESS_DENIED:
			deny++;
			break;
		default:
			break;
		}
	}
	if (deny > 0) {
		throw new AccessDeniedException(
				this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
	}
	// To get this far, every AccessDecisionVoter abstained
	checkAllowIfAllAbstainDecisions();
}

 

 

 

현재 "/greeting" 관해서 "ADMIN" 권한이 필요하다는 설정이 1개 있으므로 decisionVoters가 1이다.

 

 

WebExpressionVoter일까?

 

.mvcMatchers("/greeting").hasRole("ADMIN")

 

표현했던 권한설정을 webexpressionvotersecuritymetadatasource에 넣어주는데, 1개가 있기 때문이다.

 

투표를 알아보기 위해 vote 메서드를 확인한다.

 

@Override
public int vote(Authentication authentication, FilterInvocation filterInvocation,
		Collection<ConfigAttribute> attributes) {
	Assert.notNull(authentication, "authentication must not be null");
	Assert.notNull(filterInvocation, "filterInvocation must not be null");
	Assert.notNull(attributes, "attributes must not be null");
	WebExpressionConfigAttribute webExpressionConfigAttribute = findConfigAttribute(attributes);
	if (webExpressionConfigAttribute == null) {
		this.logger
				.trace("Abstained since did not find a config attribute of instance WebExpressionConfigAttribute");
		return ACCESS_ABSTAIN;
	}
	EvaluationContext ctx = webExpressionConfigAttribute.postProcess(
			this.expressionHandler.createEvaluationContext(authentication, filterInvocation), filterInvocation);
	boolean granted = ExpressionUtils.evaluateAsBoolean(webExpressionConfigAttribute.getAuthorizeExpression(), ctx);
	if (granted) {
		return ACCESS_GRANTED;
	}
	this.logger.trace("Voted to deny authorization");
	return ACCESS_DENIED;
}

 

webExpressionConfigAttribute를 주의한다. securityMetaDataSource에서 꺼낸 정보이다.

 

 

WebExpressionConfigAttribute webExpressionConfigAttribute = findConfigAttribute(attributes);

 

 

이 페이지는 "ROLE_ADMIN" 권한이 필요하다고 나와있다. 하지만, 권한이 없기 떄문에, voter는 DENIED를 리턴한다.

 

 

* accessDecisionManager를 커스터마이징 한다면?

 

.authorizeRequests(
        authority-> authority
            .mvcMatchers("/greeting").hasRole("ADMIN")
            .anyRequest().authenticated()
            .accessDecisionManager(filterAccessDecisionManager())
);

 

.mvcMatchers().anyRequest()는 모두 무시되고 아래의 decide()에 따라 모두 통과된다.

 

AccessDecisionManager filterAccessDecisionManager() {
  return new AccessDecisionManager() {
      @Override
      public void decide(Authentication authentication, Object object,
                         Collection<ConfigAttribute> configAttributes)
              throws AccessDeniedException, InsufficientAuthenticationException {
//                throw new AccessDeniedException("접근 금지");
          return;
      }

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

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

 

 

 

@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/greeting")
public String greeting() {
    return "hello";
}

 

Controller에 @PreAuthorize를 사용하려면 꼭 주의해야하는 것이 있다. Method Global 위원회를 꼭 소집시켜야 한다.

 

@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfiguration extends GlobalMethodSecurityConfiguration {

}

 

@EnableGlobalMethodSecurity(prePostEnabled = true)가 꼭 있어야 메서드에서 권한 검사를 한다. 기본값은 false이고, 메서드 인증을 담당하는 GlobalMethodSecurityConfiguration을 커스터마이징 해본다.

 

작동원리를 알아보기위해 MethodSecurityInterceptor를 살펴본다. Filter 관련 Interceptor와 마찬가지로 invoke 메서드 안에 beforeInvocation이 있으며 똑같은 투표과정을 거친다. 주의 할 것은 filter 관련 interceptor와 별도로 servlet에서 정책을 지닌다는 것이다.

 

@Override
public Object invoke(MethodInvocation mi) throws Throwable {
	InterceptorStatusToken token = super.beforeInvocation(mi);
	Object result;
	try {
		result = mi.proceed();
	}
	finally {
		super.finallyInvocation(token);
	}
	return super.afterInvocation(token, result);
}

 

 

 

MethodSecurityInterceptor에서는 투표자가 3이다. 이를 통해서, FilterSecurityInterceptor와는 완전히 다른 위치에 존재하고 구성된다는 것을 다시한번 알 수 있다.

 

나는 user권한으로 로그인했는데 @PreAuthroize는 admin이기 때문에 통과하지 못하고 DENY가 되어 AccessDeniedException이 발생한다.

 

 

 

Method 권한 위원회는 Global하니까 다른 방법으로 구성을 만들어보자

 

protected AccessDecisionManager accessDecisionManager() {
	List<AccessDecisionVoter<?>> decisionVoters = new ArrayList<>();
	if (prePostEnabled()) {
		ExpressionBasedPreInvocationAdvice expressionAdvice = new ExpressionBasedPreInvocationAdvice();
		expressionAdvice.setExpressionHandler(getExpressionHandler());
		decisionVoters.add(new PreInvocationAuthorizationAdviceVoter(expressionAdvice));
	}
	if (jsr250Enabled()) {
		decisionVoters.add(new Jsr250Voter());
	}
	RoleVoter roleVoter = new RoleVoter();
	GrantedAuthorityDefaults grantedAuthorityDefaults = getSingleBeanOrNull(GrantedAuthorityDefaults.class);
	if (grantedAuthorityDefaults != null) {
		roleVoter.setRolePrefix(grantedAuthorityDefaults.getRolePrefix());
	}
	decisionVoters.add(roleVoter);
	decisionVoters.add(new AuthenticatedVoter());
	return new AffirmativeBased(decisionVoters);
}

 

긍정 위원회의 구성을 다음과 같이 조금 바꿔본다.

 

@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfiguration extends GlobalMethodSecurityConfiguration {
  protected AccessDecisionManager accessDecisionManager() {
      List<AccessDecisionVoter<?>> decisionVoters = new ArrayList<>();
      ExpressionBasedPreInvocationAdvice expressionAdvice = new ExpressionBasedPreInvocationAdvice();
      expressionAdvice.setExpressionHandler(getExpressionHandler());

      decisionVoters.add(new PreInvocationAuthorizationAdviceVoter(expressionAdvice));
      decisionVoters.add(new RoleVoter());
      decisionVoters.add(new AuthenticatedVoter());
      return new AffirmativeBased(decisionVoters);
  }
}

 

decisionVoter는 총 3개이다.

 

임의의 voter를 추가한다. (무조건 통과시켜주는 voter다.)

 

public class CustomVoter implements AccessDecisionVoter<MethodInvocation> {
  @Override
  public boolean supports(ConfigAttribute attribute) {
      return true;
  }

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

  @Override
  public int vote(Authentication authentication, MethodInvocation object, Collection<ConfigAttribute> attributes) {
      return ACCESS_GRANTED;
  }
}

 

 

@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfiguration extends GlobalMethodSecurityConfiguration {
  protected AccessDecisionManager accessDecisionManager() {
      List<AccessDecisionVoter<?>> decisionVoters = new ArrayList<>();
      ExpressionBasedPreInvocationAdvice expressionAdvice = new ExpressionBasedPreInvocationAdvice();
      expressionAdvice.setExpressionHandler(getExpressionHandler());

      decisionVoters.add(new PreInvocationAuthorizationAdviceVoter(expressionAdvice));
      decisionVoters.add(new RoleVoter());
      decisionVoters.add(new AuthenticatedVoter());
      decisionVoters.add(new CustomVoter());

      return new AffirmativeBased(decisionVoters);
  }
}

 

decisionVoters.add(new Customer())로 추가한다.

 

 

 

CustomVoter가 추가되었고, 무조건 ACCESS를 하기 때문에 통과된다.

 

 

* 하지만 만약에 affirmative 방식을 만장일치인 Unanimous방식으로 바꾼다면?

 

return new UnanimousBased(decisionVoters);

 

만장일치로 ACCESS 권한이어야 하는데, PreInvocationAuthorizationAdviceVoter로 실패하는 케이스 있으므로 DENY 된다.

 

UnanimouseBased 방식은 다음과 같다.

 

@Override
@SuppressWarnings({ "rawtypes", "unchecked" })
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> attributes)
		throws AccessDeniedException {
	int grant = 0;
	List<ConfigAttribute> singleAttributeList = new ArrayList<>(1);
	singleAttributeList.add(null);
	for (ConfigAttribute attribute : attributes) {
		singleAttributeList.set(0, attribute);
		for (AccessDecisionVoter voter : getDecisionVoters()) {
			int result = voter.vote(authentication, object, singleAttributeList);
			switch (result) {
			case AccessDecisionVoter.ACCESS_GRANTED:
				grant++;
				break;
			case AccessDecisionVoter.ACCESS_DENIED:
				throw new AccessDeniedException(
						this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
			default:
				break;
			}
		}
	}
	// To get this far, there were no deny votes
	if (grant > 0) {
		return;
	}
	// To get this far, every AccessDecisionVoter abstained
	checkAllowIfAllAbstainDecisions();
}

 

ACCESS_GRANTED가 아니면 바로 AccessDeniedException 예외를 바생시킨다.

 

* ConsensusBased 방식은 다음과 같다.

 

return new ConsensusBased(decisionVoters);

 

@Override
@SuppressWarnings({ "rawtypes", "unchecked" })
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)) {
		if (this.allowIfEqualGrantedDeniedDecisions) {
			return;
		}
		throw new AccessDeniedException(
				this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
	}
	// To get this far, every AccessDecisionVoter abstained
	checkAllowIfAllAbstainDecisions();
}

 

만약 찬성 반대가 동률이라면, 그때 ACCESS로 처리한다는 구현이 되어 있다. 만약 동률일 때 예외를 발생시키고 싶다면 다음처럼 값을 변경하면 된다.

 

ConsensusBased committee = new ConsensusBased(decisionVoters);
committee.setAllowIfEqualGrantedDeniedDecisions(false);
return committee;

 

* @PreAuthorize의 메서드 수준은 어떨까?

 

 

@PreAuthorize("hasRole('USER')")
@GetMapping("/greeting/{name}")
public String greeting(@PathVariable String name) {
    return "hello " + securityMessageService.message(name);
}

 

Controller에서는 USER 권한이다.

 

@Service
public class SecurityMessageService {
  @PreAuthorize("hasRole('ADMIN')")
  public String message(String name) {
      return name;
  }
}

 

Service에서는 ADMIN 권한이다. 따라서, 정상적으로 테스트가 되지 않는다. 로그인해서 요청하는 계정은 권한이 USER이기 떄문에 Service에서 막힌다. 

 

Controller에서도 권한 검사를 하지만, Service에서도 권한 검사가 가능하고, Repository에서도 권한 검사가 가능하다. 하지만 이 3가지 모두 독립적인 공간이 아닌 Global Method 권한 위원회에서 처리한다. 

 

 

 

즉, Filter에서 검사 한번, Controller에서 검사 한번, Service에서 검사가 일어나며, 세번쨰 attemptAuthorization의 내부를 검사한다.

 

 

 

해당 경우는 3번째 검사에서 vote() 메서드의 PreInvocationAttribute preAttr = findPreInvocationAttribute(attributes); 에 담긴 preAttr의 조건이다. Service에 정의한 'hasRole('ADMIN')' 때문에 테스트는 실패한다.

 

decisionVoters.add(new PreInvocationAuthorizationAdviceVoter(expressionAdvice));
decisionVoters.add(new RoleVoter());
decisionVoters.add(new AuthenticatedVoter());
decisionVoters.add(new CustomVoter());

return new AffirmativeBased(decisionVoters);

 

만약 어떠한 경우든지 통과시키는 CustomVoter()를 포함시키고 다시 시도하면 통과한다.

 

 

이를 통해 Method Global 위원회는 Controller 뿐만 아니라, Service, Repository에도 관여한다는 사실을 알 수 있다.

 

 

 

반응형

'Spring > Spring Security' 카테고리의 다른 글

메서드 후처리  (0) 2021.10.19
권한위원회 voter  (0) 2021.10.17
권한체크 오류처리  (0) 2021.10.15
세션관리 ( 동시접속 )  (0) 2021.10.14
RememberMe  (0) 2021.10.12