dev/Java&Spring

Expression-based security 를 위한 커스텀 메소드 표현식 만들기

lugi 2018. 8. 2. 05:40

Spring Security OAuth를 이용해 토큰 발급 서버를 https://github.com/gnu-gnu/spring-boot-oauth-authserver 에 만들어 보았다.

리소스 서버도 구현 중인데, 일단 필요한 기능을 임시로 구현하면서 이미 완성된 토큰 발급 서버에 추가해서 구현하고 있다.


현재 구상하고 있는 모습은 

1. Web Security 는 각자 서비스에서 구현하도록 한다.

2. Spring Security OAuth의 Resource Server는 JAR를 Dependency에 넣으면 자동으로 구성한다.

인데


2와 같이 구현할 경우 JAR를 의존성에 넣기만 하면 리소스 서버가 구성되어 편리할 수는 있겠으나 @EnableResourceServer를 이용한 Resource Server를 설정 부분이 의존성 JAR에 종속된다.

그러므로 추가적으로 인가와 관련된 세부 설정이 필요한 경우 Scope 혹은 Token의 특정 속성을 이용하여 인가 프로세스를 직접 구현할 수 있도록 하기 위해 Custom Expression을 만들어 보았다.



Method Expression 기반 인가 프로세스가 이루어지는 순서를 살펴본 결과

1. FilterChainProxy에 있는 Web Security 관련 Filter 를 모두 통과한다..

2. DispatcherServlet을 거쳐 RequestMappingHandlerAdapter의 내용을 따라 컨트롤러를 찾아간다.

3. 그 과정에서 MethodSecurityInterceptor를 거치게 되고, 이 과정에서 SpElExpression을 해석한 결과를 통해 인가 여부를 결정한다.

의 순서인 것 같다.

그러므로 당연한 이야기지만 WebSecurityConfigurerAdapter 서 설정 해 놓은 Filter를 모두 통과하지 못 했을 경우에는 의미가 없다.

(해당 필터들은 https://docs.spring.io/spring-security/site/docs/current/reference/html/security-filter-chain.html#filter-ordering 참조)


Method Expression 기반 ACL은 Web Security를 이미 통과한 경우 사용자가 메소드를 통해 부가적인 인증 로직을 적용하거나, 혹은 Web의 Endpoint가 아닌 Service Layer등에서 인가 프로세스가 필요할 때 유용하다.


1차적인 접근 제어는 Web security에 맡기고, OAuth token을 이용한 부가적인 접근 제어를 위에서 말한 것과 조합하여 Method에 어노테이션으로 할 생각이다.


구현 해 주어야 하는 부분은 다음과 같다

1. SecurityExpressionRoot : 스프링 시큐리티 표현식을 연산하는 기본 객체

2. MethodSecurityExpression : 표현식을 실제로 처리하는 메소드를 담고 있는 클래스

3. MethodSecurityExpressionHandler : GlobalMethodSecurityConfiguration에 핸들러로 등록될 클래스

4. GlobalMethodSecurityConfiguration : Expression-based security를 설정하는 Bean

5. 실제로 표현식을 사용하는 컨트롤러


구현한 소스코드는 아래와 같다

아래에 언급되는 부분은 스프링 부트 1.5.10을 기준으로 작성한 코드이다.

package com.gnu.AuthServer.method;

import org.springframework.security.access.expression.SecurityExpressionRoot;
import org.springframework.security.core.Authentication;

/**
 * 
 * MethodSecurityExpressionRoot는 modifier가 왜 public이 아닌지 알 수가 없음.
 * 그냥 바로 써도 될 것 같은데 인스턴스 생성이 불가능하므로 SecurityExpressionRoot를 상속하여 구현 
 * 아래에 위 클래스의 modifier 관련 이슈가 있음
 * @see https://github.com/spring-projects/spring-security/pull/4266
 * @author gnu-gnu(geunwoo.j.shim@gmail.com)
 *
 */
public class AuthServerSecurityExpressionRoot extends SecurityExpressionRoot {
    public AuthServerSecurityExpressionRoot(Authentication authentication) {
        super(authentication);
    }
}

SecurityExpressionRoot를 구현한 클래스이다. 이 클래스는 스프링 시큐리티 표현식을 연산하는 기본 객체가 된다. 

SecurityExpressionRoot 클래스만으로도 hasAuthority, hasRole, permitAll, denyAll, isAnonymous, isFullyAuthenticated 등 평소 많이 보아왔던 Security Exresspion 에 해당하는 메소드들을 담고 있다. 그러나 이 클래스는 추상클래스라 인스턴스를 생성할 수가 없다.

약간 의아한 것이 이미 이 클래스를 구현한 MethodSecurityExpressionRoot 클래스가 있는데, 이 클래스는 public class가 아니라서 내가 만든 클래스에서는 new로 불러올 수가 없다. 이 클래스를 public으로 바꿔 달라는 이슈가 https://github.com/spring-projects/spring-security/issues/2251https://github.com/spring-projects/spring-security/pull/4266 에 제기되어 있는 것을 확인하였다. 6년 전부터 제기된 이슈인데 바꿔주지 않는 것을 보건데 의도가 있는 것 같은데 의도는 알 수가 없다.







package com.gnu.AuthServer.method;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;

import com.gnu.AuthServer.config.AuthServerWebSecurityConfig;

/**
 * 
 * 이 클래스의 public boolean 메소드들이 MethodSecurityExpression 으로 쓰임(eg : PreAuthorize, PostAuthorize)
 * 
 * @author gnu-gnu(geunwoo.j.shim@gmail.com)
 *
 */
public class AuthServerMethodSecurityExpression {

    private Authentication auth;
    private static final Logger logger = LoggerFactory.getLogger(AuthServerWebSecurityConfig.class);

    public AuthServerMethodSecurityExpression(Authentication auth) {
        this.auth = auth;
    }
    /**
     * #auth.isOk() expression을 호출할 경우 이 메소드를 call하게 된다. 이 메소드의 결과가 true, false 냐에 따라 인가  여부가 결정됨
     * @return
     */
    public boolean isOk(boolean bool) {
        logger.info(auth.toString());
        return bool;
    }
}

실제로 표현식이 처리되는 MethodSecurityExpression이다.

컨트롤러에서는 StandardEvalutationContext의 prefix+이 클래스의 public boolean 메소드의 이름 (예 : auth.isOk)을 컨트롤러에서 표현식으로 사용한다. 또한 이 메소드는 예시를 위해 boolean을 파라미터로 받고 있다. 이 메소드의 리턴 결과 true/false에 따라 인증 성공여부가 결정된다. 그리고 sec에서 auth 정보를 생성자에 넣어주었기 때문에 이 클래스에서 authentication 정보를 활용할 수 있다. 필요시 authentication 객체를 활용하여 인증 처리를 한다. 






package com.gnu.AuthServer.method;

import org.aopalliance.intercept.MethodInvocation;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.core.Authentication;
/**
 * MethodSecurityExpression을 처리하는 핸들러
 * @author gnu-gnu(geunwoo.j.shim@gmail.com)
 *
 */
public class AuthServerMethodSecurityExpressionHandler extends DefaultMethodSecurityExpressionHandler{

    @Override
    public StandardEvaluationContext createEvaluationContextInternal(Authentication auth, MethodInvocation mi) {
        AuthServerSecurityExpressionRoot root = new AuthServerSecurityExpressionRoot(auth);
        root.setTrustResolver(getTrustResolver());
        root.setPermissionEvaluator(getPermissionEvaluator());
        root.setRoleHierarchy(getRoleHierarchy());
        StandardEvaluationContext sec = super.createEvaluationContextInternal(auth, mi);
        sec.setRootObject(root);
        sec.setVariable("auth", new AuthServerMethodSecurityExpression(auth));
        return sec;
    }
}

MehodSecurityExpressionHandler이다 createEvaultationContextInternal을 override하여 authentication 정보와 표현식으로 넘어오는 MethodInvocation (이 예제에서는 isok) 정보를 처리한다. 위에서 구현한 SecurityExpressionRoot에 auth를 설정해주고, 기타 잡다한 resolver, evauluator등을 설정 해 준다. 그리고 EvaluationContext를 설정해주는데, 이때 StandardEvalutationContext를 super로 넘기지 않고 직접 new StandardEvalutationContext(auth); 로 지정해주면 MethodInvovation 정보를 넘길 수 없고, super에 구현되어 있는 ParameterNameDiscoverer를 넘길 수가 없기 때문에 super.createEvaluationContextInternal을 해주어 MethodSecurityEvaluationContext 를 생성하도록 한다.

그리고 root 를 설정해주고, 표현식으로 사용할 메소드와 prefix를 지정해 준다. 여기선 auth










package com.gnu.AuthServer.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration;

import com.gnu.AuthServer.method.AuthServerMethodSecurityExpressionHandler;

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

    @Override
    protected MethodSecurityExpressionHandler createExpressionHandler() {
        return new AuthServerMethodSecurityExpressionHandler();
    }
}

GlobalMethodSecurityConfiguration 클래스를 상속한 Bean을 만들고 @EnableGlobalMethodSecurity 로 메소드 시큐리티 설정을 활성화 시킨다. prePostEnabled=true를 통해 @PreAuthorize @PostAuthorize 를 활성화 시킨다. 해당 어노테이션에는 Java5부터 도입된 @Secured 및 JSR-250 표준 롤기반 Authorize 어노테이션인 @RolesAllowed 를 사용할 수 있는 옵션이 있다. 해당 부분에 대해서는 http://www.baeldung.com/spring-security-method-security 를 참조한다.

지금까지 작성한 ExpressionHandler를 createExpressionHandler()를 override하여 등록해 준다.

이제 커스텀 어노테이션을 사용할 준비가 되었다.





    /**
     * 이 endpoint는 websecurity에서 permitAll로 오픈되어 있지만, method security가 적용된 메소드
     * 현재 isOk(boolean bool)는 bool= 값으로 들어온 true/false 에 따라 인증 성공 / 실패를 보여준다.
     * @return 인증이 성공할 경우 hello? 라는 문자열 출력
     */
    @RequestMapping("/isok")
    @PreAuthorize("#auth.isOk(#bool)")
    public @ResponseBody String isok(boolean bool) {
        return "hello?";
    }

컨트롤러를 하나 만들고 /isok 경로에 매핑되는 메소드를 하나 작성한다.

처음에 말한 것과 같이 Web security에서 막힐 경우 Method expression까지 넘어가지 않고 필터에서 권한 없음을 돌려준다.

어노테이션 기반 인가 프로세스는 Web Security에서 permitAll 된 경로에 부가적인 인가 프로세스를 부여하거나, 이미 Web Security에서 인가된 Endpoint에 대해 token등으로 부가적인 인가 프로세스를 추가할 경우에 사용한다.


@PreAuthorize("#auth.isOk(#bool)") 는 컨트롤러에 진입하기 전 컨트롤러의 파라미터 bool을 #auth (=AuthServerMethodSecurityExpression 클래스)의 isOk 메소드에 컨트롤러의 bool 파라미터를 인자로 전달한다. 이 메소드의 실행결과가 true면 컨트롤러에 진입하고, false면 권한이 없을 경우 지정된 행동 (에러 메시지, 로그인 페이지 이동, 401 unauthorized 반환 등)을 수행할 것이다.


https://github.com/gnu-gnu/spring-boot-oauth-authserver.git 의 예제를 수행할 경우

웹브라우저에서 http://localhost:9099/apps/isok?bool=true 을 접속할 경우 정상적으로 접속이 인가되어 hello? 를 표시하고

http://localhost:9099/apps/isok?bool=false 를 접속할 경우 인가를 받지 못 해 초기 페이지로 이동을 수행할 것이다








p.s. 사실 간단하게 하려면 @Component로 Bean을 만든 클래스에 public boolean 메소드를 만들고 컨트롤러에서 

    @RequestMapping("/open")
    @PreAuthorize("@customChecker.isChecked(#auth)")
    public @ResponseBody String open(Authentication auth) {
        logger.info("/open is PermitAll");
        return "open";
    }

과 같이 @[Bean 이름].[메소드 및 파라미터]를 @PreAuthorize에 넣어주어도 된다. 그러면 @Component Bean 클래스의 메소드 반환 결과에 따라 인가 여부가 결정된다. github 예제 프로젝트에서 AuthServerMethodSecurityConfig 의 override를 주석처리하고http://localhost:9099/apps/open 을 접속한 후 콘솔 로그를 확인 해 보면 알 수 있다.