본문 바로가기

Spring 정리/Spring Security

권한체크 오류처리

728x90
반응형

Interceptor 권한검사는 2곳에서 가능하다. FilterSecurityInterceptor 혹은 MethodSecurityInterceptor 이며 각각 필터, 메서드에서 검사한다. 해당 검사기능들이 Filter에 있는지 Method에 있는지에 따라서 작동하는 시점이 달라지는 것이다. 2가지 Interceptor의 역할과 차이점을 알아본다.

 

 

*FilterSecurityInterceptor 

 

 

 

 AccessDecisionManger는 권한검사를 하며 매우 중요한 역할이다. 그 아래에 AccessVoter들이 위원회를 소집해서 조사 및 투표를 하고 들어갈 수 있으면 투표를 하고 통과하지 못하면 AccessDenied를 한다. 

 

request
      .antMatchers("/").permitAll()
      .antMatchers("/admin/**").hasRole("ADMIN")
      .anyRequest().authenticated()

 

 url로 그룹화된 페이지들을 시큐리티 설정으로 검사할 수 있으며 Filter 수준에서 이루어진다. 설정된 검사는 SecurityMetadataSource에서 configAttribute들이 설정된 권한의 근거자료를 가지고 처리한다.

 

 (MethodSecurityInterceptor에서 해결하는 방법은 @PreAuthorize, @PostAuthorize 등이 있으며 Controller, Service에서 애노테이션을 달아서 해결한다.)

 

AOP 개념을 따르고 있으므로 PointCut을 통해서 검사한다. 내가 어떤 상태에서 검사를 요청했는지 Invocator에 담아서 전달한다.

 

 

*ExceptionTranslationFilter

 

 ExceptionTranslaterFilterFilterSecurityInterceptor에서 발생한 오류들을 가로채서 처리한다. AuthenticationExceptionAccessDeniedException 2개에 해당한다. 그 이외의 오류는 ControllerAdvice를 통해서 해결한다. ( ExceptionTranslaterFilter는 제공하는 기능이 많기 때문에 커스터마이징을 하지 않는 것을 권장한다. )

 

 

AuthenticationException 다시, 로그인을 해야 들어올 수 있어!라는 예외가 된다. (인증 실패)

 

AccessDeniedException은 네가 누구인지 아는데 여기에 접근할 수 없어!라는 예외가 된다. (권한(인가) 없음)

 

 

AccessDeniedException는 권한이 없다는 예외인데, isAnonymous()isRememberMe()인지 검사한다. 만약 isRememberMe()가 true이면 RequestCahce를 가지고 AuthenticationException취급하여 로그인 페이지로 이동한다. 익명의 사용자인 isAnonymous()도 마찬가지로, 권한이 없어서 요청이 거부당하지 않고 인증 예외로 로그인 페이지로 이동한다. rememberMe경우에도 모든 권한을 가지고 있지 않고 특정 권한만 가질 수 있다보니, USER가 ADMIN에 접근하는 등의 상황에서 ADMIN 권한을 가진 계정으로 로그인을 요청한다.

 

 

AuthenticationEntryPoint는 직접 정의할 수 있고 기본적으로 LoginUrlAuthenticationEntryPoint를 가지고 있으며 commence를 통해 로그인 페이지로 이동시킨다. RequestCache 덕분에, 로그인을 하면 정상적으로 이전 페이지로 리다이렉트 된다.

 

AccessDeniedHandler도 보통 accessDniedErrorPage를 설정해서 errorPage로 리다이렉트하는 역할을 한다.

 

 

 

*ExceptionTranslaterFilter 코드

 

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
		throws IOException, ServletException {
	try {
		chain.doFilter(request, response);
	}
	catch (IOException ex) {
		throw ex;
	}
	catch (Exception ex) {
		// Try to extract a SpringSecurityException from the stacktrace
		Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
		RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer
				.getFirstThrowableOfType(AuthenticationException.class, causeChain);
		if (securityException == null) {
			securityException = (AccessDeniedException) this.throwableAnalyzer
					.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
		}
		if (securityException == null) {
			rethrow(ex);
		}
		if (response.isCommitted()) {
			throw new ServletException("Unable to handle the Spring Security Exception "
					+ "because the response is already committed.", ex);
		}
		handleSpringSecurityException(request, response, chain, securityException);
	}
}

 

doFilter로 다음 필터 인증으로 넘어가도록 하지만, 만약 예외가 발생한다면 AuthenticationException인지 AccessDeniedException인지 구분을 해서 handleSpringSecurityException에 해당 예외를 넘긴다.

 

private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response,
		FilterChain chain, RuntimeException exception) throws IOException, ServletException {
	if (exception instanceof AuthenticationException) {
		handleAuthenticationException(request, response, chain, (AuthenticationException) exception);
	}
	else if (exception instanceof AccessDeniedException) {
		handleAccessDeniedException(request, response, chain, (AccessDeniedException) exception);
	}
}

 

다시 한번 구체적으로 어느 예외에 해당하는지 검사한다음, 실행 메서드를 분기한다.

 

*AuthenticationException

 

private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response,
		FilterChain chain, AuthenticationException exception) throws ServletException, IOException {
	this.logger.trace("Sending to authentication entry point since authentication failed", exception);
	sendStartAuthentication(request, response, chain, exception);
}

protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
		AuthenticationException reason) throws ServletException, IOException {
	// SEC-112: Clear the SecurityContextHolder's Authentication, as the
	// existing Authentication is no longer considered valid
	SecurityContextHolder.getContext().setAuthentication(null);
	this.requestCache.saveRequest(request, response);
	this.authenticationEntryPoint.commence(request, response, reason);
}

 

this.requestCache.saveRequest(request, response)는 정상적으로 로그인이 성공하면, 마지막에 요청했던 페이지로 이동하기 위함이다. this.authenticationEntryPoint.commence는 로그인 페이지로 이동시키는 역할이다.

 

 

 

*AccessDeniedException

 

private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
		FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
	Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
	boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
	if (isAnonymous || this.authenticationTrustResolver.isRememberMe(authentication)) {
		if (logger.isTraceEnabled()) {
			logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied",
					authentication), exception);
		}
		sendStartAuthentication(request, response, chain,
				new InsufficientAuthenticationException(
						this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
								"Full authentication is required to access this resource")));
	}
	else {
		if (logger.isTraceEnabled()) {
			logger.trace(
					LogMessage.format("Sending %s to access denied handler since access is denied", authentication),
					exception);
		}
		this.accessDeniedHandler.handle(request, response, exception);
	}
}

protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
		AuthenticationException reason) throws ServletException, IOException {
	// SEC-112: Clear the SecurityContextHolder's Authentication, as the
	// existing Authentication is no longer considered valid
	SecurityContextHolder.getContext().setAuthentication(null);
	this.requestCache.saveRequest(request, response);
	this.authenticationEntryPoint.commence(request, response, reason);
}

 

isAnonymous()이면 로그인이 되지 않았기 때문에, 접근이 제한되면서 다시 로그인하라는 예외가 발생한다. isRememberMe()의 경우에도 재로그인을 하라는 예외를 발생시킨다. 이외의 예외들은 accessDeniedHandler.handle()을 통해서 처리된다.

 

*시나리오 상황

 

remember-me를 체크 한 유저 권한을 가진 사용자가 세션이 만료된다. 페이지 이동을 하려고 하는데 remember-me토큰을 가지고 있기 때문에 remember-me로 로그인이 된다. 어드민 관리자 페이지로 이동을 하려고 하면 권한이 없다는 문구가 떠야 하지만, 재로그인 페이지로 이동을 한다. 왜 권한이 없는 문구가 나오지 않고 재로그인 페이지로 이동하는가?

 

rememberMe로 로그인을 하는 경우도 어떻게 하면 권한 없는 문구를 나오게 할 수 있을까?

 

@Bean
PersistentTokenBasedRememberMeServices rememberMeServices(){
  return new PersistentTokenBasedRememberMeServices(
          "hello",
          spUserService,
          tokenRepository()) {
      @Override
      protected Authentication createSuccessfulAuthentication(HttpServletRequest request, UserDetails user) {
          return new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), null);
      }
  };
}

 

Persistent 방식의 remember-me 토큰 인증을 변경하면 된다. UsernamePasswordAuthenticationToken 방식으로 인증정보를 변경해, 아이디와 비밀번호는 정상적으로 로그인이 되어있지만, 권한은 null을 주어 권한이 없다는 예외를 발생시키도록 만든다. remember-me 토큰을 가지고 로그인이 되어있다면, 권한이 없는 페이지로 요청한다면 401(UnAuthorized)에러가 뜨면서 로그인페이지로 이동하지만, 강제로 403(AccessDenied)에러를 발생시키면 된다.

 

 

* FilterSecurityInterceptor 살펴보기

 

*정상적인 요청에 어떻게 진행될까? ("/"에 permitAll() 된 상황)

 

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

 

Filter는 invoke 메서드로 제어한다. 보통 beforeInvocation으로 이동해서 권한 검사를 한다.

 

beforeInvocation : Security Config 에서 설정한 접근 제한을 체크한다.
finallyInvocation : RunAs 권한을 제거한다.
afterInvocation : AfterInvocationManager 를 통해 체크가 필요한 사항을 체크한다. 특별히 설정하지 않으면 AfterInvocationManager 는 null 이다.

 

 

AbstractSecurityInteceptor라는 상위 클래스에서 beforeInvocation 메서드를 살펴본다. 

 

protected InterceptorStatusToken beforeInvocation(Object object) {
	Assert.notNull(object, "Object was null");
	if (!getSecureObjectClass().isAssignableFrom(object.getClass())) {
		throw new IllegalArgumentException("Security invocation attempted for object " + object.getClass().getName()
				+ " but AbstractSecurityInterceptor only configured to support secure objects of type: "
				+ getSecureObjectClass());
	}
	Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);
	if (CollectionUtils.isEmpty(attributes)) {
		Assert.isTrue(!this.rejectPublicInvocations,
				() -> "Secure object invocation " + object
						+ " was denied as public invocations are not allowed via this interceptor. "
						+ "This indicates a configuration error because the "
						+ "rejectPublicInvocations property is set to 'true'");
		if (this.logger.isDebugEnabled()) {
			this.logger.debug(LogMessage.format("Authorized public object %s", object));
		}
		publishEvent(new PublicInvocationEvent(object));
		return null; // no further work post-invocation
	}
	if (SecurityContextHolder.getContext().getAuthentication() == null) {
		credentialsNotFound(this.messages.getMessage("AbstractSecurityInterceptor.authenticationNotFound",
				"An Authentication object was not found in the SecurityContext"), object, attributes);
	}
	Authentication authenticated = authenticateIfRequired();
	if (this.logger.isTraceEnabled()) {
		this.logger.trace(LogMessage.format("Authorizing %s with attributes %s", object, attributes));
	}
	// Attempt authorization
	attemptAuthorization(object, attributes, authenticated);
	if (this.logger.isDebugEnabled()) {
		this.logger.debug(LogMessage.format("Authorized %s with attributes %s", object, attributes));
	}
	if (this.publishAuthorizationSuccess) {
		publishEvent(new AuthorizedEvent(object, attributes, authenticated));
	}

	// Attempt to run as a different user
	Authentication runAs = this.runAsManager.buildRunAs(authenticated, object, attributes);
	if (runAs != null) {
		SecurityContext origCtx = SecurityContextHolder.getContext();
		SecurityContextHolder.setContext(SecurityContextHolder.createEmptyContext());
		SecurityContextHolder.getContext().setAuthentication(runAs);

		if (this.logger.isDebugEnabled()) {
			this.logger.debug(LogMessage.format("Switched to RunAs authentication %s", runAs));
		}
		// need to revert to token.Authenticated post-invocation
		return new InterceptorStatusToken(origCtx, true, attributes, object);
	}
	this.logger.trace("Did not switch RunAs authentication since RunAsManager returned null");
	// no further work post-invocation
	return new InterceptorStatusToken(SecurityContextHolder.getContext(), false, attributes, object);

}

 

 

Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);

 

요청한 정보에 대해서 securityMeataDataSource()를 가져다가 주라는 요청을 날린다.

 

 

 

request
    .antMatchers("/").permitAll()
    .antMatchers("/admin/**").hasRole("ADMIN")
    .anyRequest().authenticated()

 

시큐리티 필터 설정에서 "/" 요청을 permitAll()의 설정이 여기서 검사된다.

 

 

attemptAuthorization()으로 권한생성을 시도한다.

 

private void attemptAuthorization(Object object, Collection<ConfigAttribute> attributes,
		Authentication authenticated) {
	try {
		this.accessDecisionManager.decide(authenticated, object, attributes);
	}
	catch (AccessDeniedException ex) {
		if (this.logger.isTraceEnabled()) {
			this.logger.trace(LogMessage.format("Failed to authorize %s with attributes %s using %s", object,
					attributes, this.accessDecisionManager));
		}
		else if (this.logger.isDebugEnabled()) {
			this.logger.debug(LogMessage.format("Failed to authorize %s with attributes %s", object, attributes));
		}
		publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, ex));
		throw ex;
	}
}

 

accessDecisionManager에게 권한 검사와 인증을 만들어도 되는지 판단을 위임한다.

 

AffirmativeBase 클래스에서 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();
}

 

 voter 투표권자들이 switch문으로 deny를 발생시킬지 아닐지 확인한다. 1명이라도 ACCESS_GRANTED가 있다면 권한 검사가 통과되고 그렇지 않고 모두  ACCESS_DENIED라면 권한 검사가 통과하지 못하고 예외가 발생한다. 결론적으로 voterpermitAll()이야!라는 판단과 함께 통과시켜준다.

 

 

WebExopressionVoter 클래스의 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;
}

 

 

// Attempt to run as a different user
Authentication runAs = this.runAsManager.buildRunAs(authenticated, object, attributes);

 

beforeInvocation에서 runAs를 구현하는데 추가적인 권한을 더 넣어주고 나갈 때 빼기 위해서 코드로 표현한 것이다. (대부분은 runAs를 구현하지 않는다.)

 

 

*비정상적인 요청에 어떻게 진행될까?("/admin/hello에 접속하는 상황)

 

권한이 없더라도 정상적으로 beforeInvocation 메서드 안으로 들어갈 수 있다. 

 

Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);

 

로그인 시도중인 계정의 securityMetaSource를 확인하는데, 해당 경로는 ADMIN 권한을 요구하고 있다.

 

 

 

ADMIN 권한이 없기 때문에 ExceptionTranslationFilter에서 doFilter를 실패하고 catch 문으로 이동하여 예외를 처리한다.

 

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
		throws IOException, ServletException {
	try {
		chain.doFilter(request, response);
	}
	catch (IOException ex) {
		throw ex;
	}
	catch (Exception ex) {
		// Try to extract a SpringSecurityException from the stacktrace
		Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
		RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer
				.getFirstThrowableOfType(AuthenticationException.class, causeChain);
		if (securityException == null) {
			securityException = (AccessDeniedException) this.throwableAnalyzer
					.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
		}
		if (securityException == null) {
			rethrow(ex);
		}
		if (response.isCommitted()) {
			throw new ServletException("Unable to handle the Spring Security Exception "
					+ "because the response is already committed.", ex);
		}
		handleSpringSecurityException(request, response, chain, securityException);
	}
}

 

 

 

if (securityException == null) {
	securityException = (AccessDeniedException) this.throwableAnalyzer
			.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
}

 

해당 예외는 여기서 걸리고, AccessDeniedException이 발생하여 해당 예외가 처리된다.

 

 

* AccessDeniedHandler, AuthenticationEntryPoint 커스터마이징 하기

 

 

 

* AccessDeniedHandler의 작동은?

 

 

public class YouCannotAccessUserPage extends AccessDeniedException {
  public YouCannotAccessUserPage() {
      super("유저페이지 접근 거부");
  }
}

 

 

@PreAuthorize("hasAnyAuthority('ROLE_USER')")
@GetMapping("/user-page")
public String user() {
    if(true) {
        throw new YouCannotAccessUserPage();
    }
    return "UserPage";
}

 

"/user-page"로 접근하면 무조건 커스터마이징한 예외를 던지도록 강제한다.

 

 

public class CustomDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException, ServletException {
        if(accessDeniedException instanceof YouCannotAccessUserPage) {
            request.getRequestDispatcher("/access-denied").forward(request, response);
        } else {
            request.getRequestDispatcher("/access-denied2").forward(request, response);
        }
    }
}

 

AccessDeniedException을 상속하여 새롭게 만든 예외인 YouCannotAccessUserPage의 경우에는 "/access-denied"로 경로를 보낸다. 즉, 커스마이징하여 만든 예외에 따라서 DispatcherServlet의 처리 경로를 변경해 줄 수 있다.

 

@GetMapping("/access-denied")
public String accessDenied() {
    return "AccessDenied";
}

@GetMapping("/access-denied2")
public String accessDenied2() {
    return "AccessDenied2";
}

 

.exceptionHandling(error->
                error
                        .accessDeniedHandler(new CustomDeniedHandler())
                        .accessDeniedPage("/access-denied")
        )

 

accessDeniedHandler를 추가하면 accessDeniedPage가 무시된다.

 

accessDeniedHandler를 구현하여 만든 클래스는 다양한 접근거부 예외를 만들고 처리가 가능하다.

 

*AuthenticationEntryPoint 커스터마이징

 

public class CustomEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        request.getRequestDispatcher("/login-required")
                .forward(request, response);
    }
}

 

만약 비로그인상태(anonymous)로 접속을 시도하면, 처음에는 AccessDeniedException이 발생하나, isAnonymous()에 해당하기 때문에 AuthentiationException으로 전환되고, AuthenticationEntryPoint로 검사가 넘어간다.

 

@GetMapping("/login-required")
public String loginRequired() {
    return "loginRequired";
}

 

 

.exceptionHandling(error->
        error
                .accessDeniedHandler(new CustomDeniedHandler())
                .authenticationEntryPoint(new CustomEntryPoint())
                //.accessDeniedPage("/access-denied")
        )

 

따라서, 로그인 페이지로 이동하는 주소를 "/login-required" 를 변경하도록 커스터마이징하면, "로그인이 필요합니다" 등의 추가적인 문구를 추가하여 사용자에게 상황을 알려주는 디테일을 제공할 수 있다.

728x90
반응형

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

권한위원회 voter  (0) 2021.10.17
스프링 시큐리티의 권한(Authorization)  (0) 2021.10.15
세션관리 ( 동시접속 )  (0) 2021.10.14
RememberMe  (0) 2021.10.12
UserDetailsService  (0) 2021.10.11