본문 바로가기

Spring Boot/Security

* 인가 아키텍처 : 핵심 컴포넌트 *

Gradle:org.springframework.security:spring-security-core:6.5.10에 대해 다룬다

 

 - Spring Security 인가 아키텍처의 핵심 컴포넌트

 - AuthorizationManager의 설계 원리와 동작 방식

 - 주요 AuthorizationManager 구현체의 특징과 사용 예시

 - 권한 표현 방식과 GrantedAuthority의 관계 학습

 - SpEL과 RoleHierarchy를 활용한 인가 제어 방법


개요

권한 부여(Authorization) 처리 흐름

 

 Spring Security의 몇가지 권한 부여 컴포넌트들의 내부 코드를 들여다보며 권한 부여 흐름을 구체적으로 확인해보기

 


AuthorizationFilter

URL을 통해 사용자의 액세스를 제한하는 권한부여 Filter임

Spring Security 5.5버전부터 FilterSecurityInterceptor를 대체한다

 

Security 5.x AuthorizationFilter 의 일부분

public class AuthorizationFilter extends OncePerRequestFilter {
	private final AuthorizationManager<HttpServletRequest> authorizationManager;
    
    // (1)
    public AuthorizationFilter(AuthorizationManager<HttpServletRequest> authorizationManager) { // DI
    	Assert.notNull(authorizationManager, "authorizationManager cannot be null");
        this.authorizationManager = authorizationManager;
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
        HttpServletResponse response, 
        FilterChain filterChain) throws ServletException, IOException{
        // (2)
        AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
        this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
        if(decision != null && !decision.isGranted()) {
        	throw new AccessDeniedException("Access Denied");
        }
        filterChain.doFilter(request, response);
    }
}

 

  • AuthorizationFilter 객체가 생성될 때 AuthorizationManager를 DI 받는 것을 확인할 수 있다 (1)
    DI받은 AuthorizationMAnager를 통해 권한 부여 처리를 진행한다
  • DI받은 AuthorizationManager의 check() 메서드를 호출해서 적절한 권한 부여 여부를 체크한다 (2)
    AuthorizationManager의 check() 메서드는 AuthorizationManager 구현 클래스에 따라서 권한 체크 로직이 다르다

    URL 기반으로 권한 부여 처리를 하는 AuthorizationFilter는 AuthorizationManager의 구현 클래스로,
    RequestMatcherDelegatingAuthorizationManager를 사용한다.

 

 

Security 6.x AuthorizaionFilter의 일부분

package org.springframework.security.web.access.intercept;

public class AuthorizationFilter extends GenericFilterBean {
    private SecurityContextHolderStrategy securityContextHolderStrategy = 
        SecurityContextHolder.getContextHolderStrategy();
    private final AuthorizationManager<HttpServletRequest> authorizationManager;
    private AuthorizationEventPublisher eventPublisher = 
        new NoopAuthorizationEventPublisher();
    private boolean observeOncePerRequest = false;
    private boolean filterErrorDispatch = true;
    private boolean filterAsyncDispatch = true;
	
    // (1)
    public AuthorizationFilter(AuthorizationManager<HttpServletRequest> authorizationManager) {
        Assert.notNull(authorizationManager, "authorizationManager cannot be null");
        this.authorizationManager = authorizationManager;
    }

    public void doFilter(ServletRequest servletRequest, 
        ServletResponse servletResponse, 
        FilterChain chain) throws ServletException, IOException {
        
        HttpServletRequest request = (HttpServletRequest)servletRequest;
        HttpServletResponse response = (HttpServletResponse)servletResponse;
        if (this.observeOncePerRequest && this.isApplied(request)) {
            chain.doFilter(request, response);
        } else if (this.skipDispatch(request)) {
            chain.doFilter(request, response);
        } else {
            String alreadyFilteredAttributeName = this.getAlreadyFilteredAttributeName();
            request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);

            try {
            	// (2)
                AuthorizationResult result = 
                    this.authorizationManager.authorize(this::getAuthentication, request);
                this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, result);
                if (result != null && !result.isGranted()) {
                    throw new AuthorizationDeniedException("Access Denied", result);
                }

                chain.doFilter(request, response);
            } finally {
                request.removeAttribute(alreadyFilteredAttributeName);
            }

        }
    }
}

 

URL을 통해 사용자의 엑세스를 제한하는 Filter라는 점은 동일하다

핵심 기능은 동일한데 security 6.x 로 넘어오면서 바뀌거나 추가된점이 있음

  • extends가 OncePerRequestFilter에서 GenericFilterBean 으로 바뀌었음
    OncePerRequestFilter는 doFilterInternal()을 통해 자동으로 한번만 실행하는 것을 보장해줬다
    GenericFilterBean으로 바뀌면서 직접 한번만 실행하는 로직을 구현하게끔 함
  • doFilterInternal() 메서드가 doFilter() 메서드로 바뀌었다
    OncePerRequestFilter가 내부에서 doFilter를 감싸는 형태였음
    이제 doFilter() 메서드를 통해 전체 흐름을 전부 구현하게끔 바뀌었다
  • check() 메서드가 authorize() 메서드로 바뀌었다
    이름대로 단순히 확인만하는 check() 메서드에서 인증까지 하는 authorize() 메서드로 바뀌었다
// Security 5.x
AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);            	
                
// Security 6.x
AuthorizationResult result = this.authorizationManager.authorize(this::getAuthentication, request);
  • 예외가 바뀌었다
    기존에는 String으로 Access Denied라고만 던졌는데, 위의 result 객체까지 포함해서 던지게 바뀌었다
throw new AuthorizationDeniedException("Access Denied", result);

 


AuthorizationManager

이름 그대로 권한 부여 처리를 총괄하는 매니저 역할을 하는 인터페이스임

 

Security 6.x 기준 AuthorizationManager, 지원 중단을 제외한 코드

package org.springframework.security.authorization;

import java.util.function.Supplier;
import org.springframework.lang.Nullable;
import org.springframework.security.core.Authentication;

@FunctionalInterface
public interface AuthorizationManager<T> {
    default void verify(Supplier<Authentication> authentication, T object) {
        AuthorizationDecision decision = this.check(authentication, object);
        if (decision != null && !decision.isGranted()) {
            throw new AuthorizationDeniedException("Access Denied", decision);
        }
    }
}

 

 

1. Spring Security 5.7+ 인가 아키텍처

Security 5.7부터 기존의 AccessDecisionManager 대신 AuthorizationManager 가 권장된다고 한다

인가 과정을 더 단순하고 직관적으로 구성하기 위해 등장한 컴포넌트임

함수형 프로그래밍 스타일을 지원하며, 선언적 구성이 용이하다고 함

// AuthorizationManager 예제
AuthorizationManager<HttpServletRequest> manager = (authentication, request) -> {
        boolean granted = authentication.get().isAuthenticated();
    	return granted ? AuthorizationDecision.PERMIT : AuthorizationDecision.DENY;
    };

 

boolean형 granted 객체를 생성해서 인증된 사용자만 접근을 허용하는 단순한 인가 로직이다

 

 

 

2. 함수형 인터페이스 기반 설계

AuthorizationManager는 함수형 인터페이스로 정의되어 있어 람다 표현식으로 쉽게 작성이 가능하다

cheak() 메서드가 핵심이었는데, verify() 메서드로 바뀌었다.
인증 정보와 요청 정보를 입력받아 접근 허용 여부를 결정할 수 있다.

package org.springframework.security.authorization;

import java.util.function.Supplier;
import org.springframework.lang.Nullable;
import org.springframework.security.core.Authentication;

@FunctionalInterface
public interface AuthorizationManager<T> {
	// Security 6.x의 verify() 메서드
    default void verify(Supplier<Authentication> authentication, T object) {
        AuthorizationDecision decision = this.check(authentication, object);
        if (decision != null && !decision.isGranted()) {
            throw new AuthorizationDeniedException("Access Denied", decision);
        }
    }
	
    // Security 5.x의 check() 메서드
    /** @deprecated */
    @Nullable
    @Deprecated
    AuthorizationDecision check(Supplier<Authentication> authentication, T object);
}

 

verify() 메서드 안에 check() 메서드를 사용하고 있는 것을 알 수 있다

매개변수로 주입받는 인자들도 같아서 verify()를 사용하면 필터로직을 구현할 필요는 없어보인다

 

반환값인 AuthorizationDecision은 허용(PERMIT) 또는 거부(DENY)를 명시한다.

 

 

주요 메서드 및 동작 방식

메서드 설명
check() / verify() 인가 검증 수행, AuthorizationDecision 반환
and() / allOf() 여러 AuthorizationManager를 조합해서 논리 AND 구성
or() / anyOf() 여러 AuthorizationManager를 조합해서 논리 OR 구성

 

AuthorizaionManager는 check()를 통해 판단하고 verify()를 통해 집행한다

 

* 기존 AccessDecisionManager를 사용하는 코드와 혼용이 가능하지만, 신규 프로젝트에서는 AuthorizationManager를 사용하는 것이 권장된다

* and(), or() 메서드들을 통해 권한 조건을 조합할 수 있으므로 코드의 가독성이 높아진다

 

 

개념 설명
AuthorizaionManager Security 5.7+ 인가 핵심 컴포넌트
핵심 메서드 check()
반환값 AuthorizaionDecision (PERMIT / DENY)

 


주요 AuthorizaionManager 구현체

1. RequestMatcherDelegatingAuthorizationManager

AuthorizaionManager의 구현 클래스중 하나임

얘는 여기있다

package org.springframework.security.web.access.intercept;

 

직접 권한 부여 처리를 수행하지 않고 RequestMatcher 를 통해 매치되는 AuthorizationManager 구현 클래스에게 권한 부여 처리를 위임한다

 

Security 5.x의 RequestMatcherDelegatingAuthorizationManager 일부

public final class RequestMatcherDelegatingAuthorizationManager 
    implements AuthorizationManager<HttpServletRequest> {

    @Override
    public AuthorizationDecision check(Supplier<Authentication> authentication, 
        HttpServletRequest request) {
            if (this.logger.isTraceEnabled()) {
                this.logger.trace(LogMessage.format("Authorizing %s", request));
            }

            // (1)
            for (RequestMatcherEntry<AuthorizationManager<RequestAuthorizationContext>> mapping 
                : this.mappings) {
                RequestMatcher matcher = mapping.getRequestMatcher(); // (2)
                MatchResult matchResult = matcher.matcher(request);
                if (matchResult.isMatch()) {   // (3)
                    AuthorizationManager<RequestAuthorizationContext> manager = mapping.getEntry();
                    if (this.logger.isTraceEnabled()) {
                        this.logger.trace(
                            LogMessage.format("Checking authorization on %s using %s", request, manager));
                    }
                    return manager.check(authentication,
                        new RequestAuthorizationContext(request, matchResult.getVariables()));
                }
            }
            this.logger.trace("Abstaining since did not find matching RequestMatcher");
            return null;
        }
}

 

 

 

Security 6.5.10 버전의 RequestMatcherDelegatingAuthorizationManager 일부

package org.springframework.security.web.access.intercept;

public final class RequestMatcherDelegatingAuthorizationManager 
    implements AuthorizationManager<HttpServletRequest> {
    private static final AuthorizationDecision DENY = new AuthorizationDecision(false);
    private final Log logger = LogFactory.getLog(this.getClass());
    private final List<RequestMatcherEntry<AuthorizationManager<RequestAuthorizationContext>>> mappings;

    private RequestMatcherDelegatingAuthorizationManager(
        List<RequestMatcherEntry<AuthorizationManager<RequestAuthorizationContext>>> mappings) {
        Assert.notEmpty(mappings, "mappings cannot be empty");
        this.mappings = mappings;
    }
	
    // 지원 중단
    /** @deprecated */
    @Deprecated
    public AuthorizationDecision check(Supplier<Authentication> authentication, 
        HttpServletRequest request) {
        if (this.logger.isTraceEnabled()) {
            this.logger.trace(LogMessage.format("Authorizing %s", requestLine(request)));
        }

        Iterator var3 = this.mappings.iterator();
        
        // (1)
        RequestMatcherEntry mapping;
        RequestMatcher.MatchResult matchResult;
        do {
            if (!var3.hasNext()) {
                if (this.logger.isTraceEnabled()) {
                    this.logger.trace(LogMessage.of(() -> {
                        return "Denying request since did not find matching RequestMatcher";
                    }));
                }

                return DENY;
            }

            mapping = (RequestMatcherEntry)var3.next();
            RequestMatcher matcher = mapping.getRequestMatcher(); // (2)
            matchResult = matcher.matcher(request);
        } while(!matchResult.isMatch()); // (3)

        AuthorizationManager<RequestAuthorizationContext> manager = (AuthorizationManager)mapping.getEntry();
        if (this.logger.isTraceEnabled()) {
            this.logger.trace(
                LogMessage.format("Checking authorization on %s using %s", requestLine(request), manager));
        }

        return manager
            .check(authentication, new RequestAuthorizationContext(request, matchResult.getVariables()));
    }
}

 

RequestMatcherDelegatingAuthorizationManager 클래스 코드의 일부임, 5.x 버전과 6.5.10 버전이 있다

6.5.10 버전에서는 check() 메서드에 대한 지원이 중단됐다. 사용할 순 있음

 

  • check() 메서드의 내부에서 (1)과 같이 루프를 돌면서 RequestMatcherEntry 정보를 얻은 후
    (2)에서 RequestMatcher 객체를 얻는다
    6.5.10에서는 루프가 do while문으로 바뀌었다
// (1)
for (RequestMatcherEntry<AuthorizationManager<RequestAuthorizationContext>> mapping : this.mappings) {
    RequestMatcher matcher = mapping.getRequestMatcher(); // (2)
  • (3)에서 MatchRequest.isMatch()가 true이면 AuthorizationManager 객체를 얻은 뒤, 사용자의 권한을 체크한다
    여기에서 RequestMatcher는 SecurityConfiguration에서
    .anyMatchers("/orders/**").hasRole("ADMIN") 과 같은 메서드 체인 정보를 기반으로 생성된다는 사실이 중요함
if (matchResult.isMatch()) {   // (3)

 

 

 

2. AuthorityAuthorizationManager

특정한 권한(Authority)이 있는지 검사한다

간단히 권한을 비교할 때 가장 자주 사용된다고 함

hasRole(), hasAuthority() 메서드를 자주 씀

AuthorityAuthorizationManager.hasRole("ADMIN");
AuthorityAuthorizationManager.hasAuthority("READ_PRIVILEGE");

 

이런식

 

6.5.10 기준 AuthorityAuthorizationManager 일부

package org.springframework.security.authorization;

import java.util.Set;
import java.util.function.Supplier;
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.core.Authentication;
import org.springframework.util.Assert;

public final class AuthorityAuthorizationManager<T> implements AuthorizationManager<T> {
    private static final String ROLE_PREFIX = "ROLE_";
    private final AuthoritiesAuthorizationManager delegate = new AuthoritiesAuthorizationManager();
    private final Set<String> authorities;

    private AuthorityAuthorizationManager(String... authorities) {
        this.authorities = Set.of(authorities);
    }

    public void setRoleHierarchy(RoleHierarchy roleHierarchy) {
        this.delegate.setRoleHierarchy(roleHierarchy);
    }

    public static <T> AuthorityAuthorizationManager<T> hasRole(String role) {
        Assert.notNull(role, "role cannot be null");
        Assert.isTrue(!role.startsWith("ROLE_"), () -> {
            return role + 
                " should not start with ROLE_ since ROLE_ is automatically prepended when using hasRole. 
                Consider using hasAuthority instead.";
        });
        return hasAuthority("ROLE_" + role);
    }

    public static <T> AuthorityAuthorizationManager<T> hasAuthority(String authority) {
        Assert.notNull(authority, "authority cannot be null");
        return new AuthorityAuthorizationManager(new String[]{authority});
    }
}

 

 

 

3. AuthenticatedAuthorizationManager

인증(Authentication) 자체 여부만 검사한다

AuthenticatedAuthorizationManager.authenticated();
AuthenticatedAuthorizationManager.fullyAuthenticated();

 

이런식으로

 

6.5.10 기준 AuthenticatedAuthorizationManager 일부

package org.springframework.security.authorization;

public final class AuthenticatedAuthorizationManager<T> implements AuthorizationManager<T> {
    private final AbstractAuthorizationStrategy authorizationStrategy;

    public AuthenticatedAuthorizationManager() {
        this(new AuthenticatedAuthorizationStrategy());
    }

    private AuthenticatedAuthorizationManager(AbstractAuthorizationStrategy authorizationStrategy) {
        this.authorizationStrategy = authorizationStrategy;
    }

    public static <T> AuthenticatedAuthorizationManager<T> authenticated() {
        return new AuthenticatedAuthorizationManager();
    }

    public static <T> AuthenticatedAuthorizationManager<T> fullyAuthenticated() {
        return new AuthenticatedAuthorizationManager(new FullyAuthenticatedAuthorizationStrategy());
    }

    public static <T> AuthenticatedAuthorizationManager<T> rememberMe() {
        return new AuthenticatedAuthorizationManager(new RememberMeAuthorizationStrategy());
    }

    public static <T> AuthenticatedAuthorizationManager<T> anonymous() {
        return new AuthenticatedAuthorizationManager(new AnonymousAuthorizationStrategy());
    }

    private static class AuthenticatedAuthorizationStrategy 
        extends AbstractAuthorizationStrategy {
        private AuthenticatedAuthorizationStrategy() {
        }

        boolean isGranted(Authentication authentication) {
            return this.trustResolver.isAuthenticated(authentication);
        }
    }

    private abstract static class AbstractAuthorizationStrategy {
        AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();

        private AbstractAuthorizationStrategy() {
        }

        private void setTrustResolver(AuthenticationTrustResolver trustResolver) {
            Assert.notNull(trustResolver, "trustResolver cannot be null");
            this.trustResolver = trustResolver;
        }

        abstract boolean isGranted(Authentication authentication);
    }

    private static final class FullyAuthenticatedAuthorizationStrategy 
        extends AuthenticatedAuthorizationStrategy {
        private FullyAuthenticatedAuthorizationStrategy() {
        }

        boolean isGranted(Authentication authentication) {
            return this.trustResolver.isFullyAuthenticated(authentication);
        }
    }

    private static final class RememberMeAuthorizationStrategy 
        extends AbstractAuthorizationStrategy {
        private RememberMeAuthorizationStrategy() {
        }

        boolean isGranted(Authentication authentication) {
            return this.trustResolver.isRememberMe(authentication);
        }
    }

    private static final class AnonymousAuthorizationStrategy 
        extends AbstractAuthorizationStrategy {
        private AnonymousAuthorizationStrategy() {
        }

        boolean isGranted(Authentication authentication) {
            return this.trustResolver.isAnonymous(authentication);
        }
    }
}

 

fullyAuthenticated()는 Remember-me 같은 임시 인증이 아닌, 완전한 인증을 요구한다

 

 

* 관리자와 일반 사용자 페이지를 구분할 때 RequestMAtcherDelegatingAuthorizationManager가 가장 많이 쓰인다

* 인증 여부만 확인하고 싶을 땐 AuthenticatedAuthorizationManager 가 편리함

 

 

정리

구현체 설명
RequestMatcherDelegatingAuthorizationManager 요청 경로에 따라 다른 인가 매니저 실행
AuthorityAuthorizationManager 권한 비교 기반 인가
AuthenticatedAuthorizationManager 인증 여부 검사

 


권한 표현 방식

1. GrantedAuthority와 인가의 관계

인가는 기본적으로 GrantedAuthority와 비교하여 수행된다

사용자가 가진 GrantedAuthority 목록을 확인해서 필요한 권한이 있는지 검사한다

// GrantedAuthority 와 사용자의 GrantedAuthority 목록을 쭉 훑으며 확인한다
for(GrantedAuthority authority : authentication.getAuthorities()) {
	System.out.println("사용자 권한 : " + authority.getAuthority());
}

 

 

 

2. SpEL(Spring Expression Language)를 활용한 권한 표현

Security에서는 SpEL을 사용해서 권한 조건을 더욱 유연하게 지정할 수 있다

@PreAuthorize("hasRole('ADMIN') and #id == authentication.principal.id")
public void updateUser(Long id) {
	// ADMIN이면서 자기 자신의 id일때만 실행 가능하게끔 로직 구현
}

 

메서드 보안에 자주 사용되며, 데이터 기반 인가를 표현할 때 강력하다고 함

 

 

 

3. 권한 계층 구조(RoleHierarchy)

권한이 많아질수록 관리가 어려워질 수 있다

RoleHierarchy를 사용하면 상위 권한이 하위 권한을 자동으로 포함하도록 설정할 수 있다

RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_MANAGER \\n ROLE_MANAGER > ROLE_USER");

 

위 설정에 따라서 ROLE_ADMIN을 가진 사용자는 ROLE_MANAGER, ROLE_USER 권한도 자동으로 포함된다.

 

 


* SpEL은 강력하지만 복잡성을 높일 수 있으므로 남발하지 않게끔 주의하자

* 권한이 늘어나면 계층 구조를 설정하여 중복된 권한 부여를 줄이는 것이 유지보수에 효과적이다

 

개념 설명
GrantedAuthority 사용자가 가진 권한 표현하기
SpEL 조건 기반 인가 표현하기
RoleHierarchy 권한 계층 구조 설정하기

  • AuthorizationManager는 Spring Security 5.7부터 도입된 인가 핵심 컴포넌트로, 함수형 설계와 단순화를 지원한다
  • 주요 구현체로는 RequestMatcherDelegatingAuthorizationManager,
    AuthorityAuthorizationManager, AhtenticatedAuthorizationManager 가 있다
  • 인가는 기본적으로 GrantedAuthority와 비교하여 수행되며, SpEL을 활용하면 데이터 기반 조건을 표현할 수 있다
  • RoleHierarchy를 통한 권한을 계층적으로 설계하면 관리와 유지보수가 용이해진다