ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • spring security Pre-Authentication 구현 (AbstractPreAutehnticatedProcessingFilter)
    spring 2022. 3. 15. 16:57
    반응형

    spring security 의 구조를 어느정도 알고 있다고 가정하고 진행한다.

    최종 코드는 여기서 확인할 수 있다: https://github.com/bitgadak/tistory-spring-security-preauth-sample


    sprint security 필터에는 서비스에서 요구하는 인증 스펙에 따라 다양한 필터를 사용할 수 있다. 인증에 대한 여러가지 방식이 있겠지만 그 중 하나는 인증이 spring 외부에서 진행되는 pre-authentication 방식이다. 예를 들어 외부에 인증 모듈이 있고, spring 은 인증 모듈을 통과하여 인증된 사용자 ID 와 권한만 받아서 처리 하는 것이다. spring 에서는 이런 형태의 시나리오에 대한 사용할 수 있는 필터 AbstractPreAuthenticatedProcessingFilter 가 존재한다.

     

    시스템의 다른 모듈에 api 를 제공하거나 이미 인증된 사용자에 대해 api 를 제공하는 spring 모듈을 만들어야 할 경우가 생겨 구축한 것을 기록해 보려고 한다.

    이러한 api 서버를 도식화하면 아래와 같다.


    목표

    요청의 HTTP 헤더 값에 따라 권한을 판단할 것이다. 크게 두 가지 종류가 있다. 인증모듈을 통과한 사용자 요청과 시스템 내 다른 모듈에서 부터의 시스템 요청이 있다. 아래와 같이 헤더의 값에 따라서 구분하고 권한을 지정할 것이다.

    헤더 값에 따라 spring security 에서 제공하는 Authentication 에 사용자 정보와 권한을 넘긴다. 이것은 PreAuthorize 어노테이션의 hasRole() 를 통해 컨트롤러 별로 접근 권한을 구분할 것이다. 

    예를 들어 일반 사용자는 @PreAuthorize("hasRole('USER')") 가 붙은 메소드는 호출 가능하지만, @PreAuthorize("hasRole('ADMIN')") 붙은 메소드는 호출 불가능하다.

     

    사용자 요청을 위한 필터와 시스템 요청에 대한 필터 이렇게 두 필터를 AbstractPreAuthenticatedProcessingFilter 를 상속하여 구현하도록 한다. 각각 SystemAuthenticationFilter, UserAuthenticationFilter 이렇게 지정하도록 한다.


    1. gradle 을 통한 spring 프로젝트 시작

    gradle 로 프로젝트를 구축한다. spring boot plugin 을 통해 build.gradle 를 작성한다. 현시점(2020-03-15)에서 최신 버전을 사용한다.

    plugins {
        id 'org.springframework.boot' version '2.6.4'
        id 'io.spring.dependency-management' version '1.0.11.RELEASE'
        id 'java'
    }
    
    group 'org.example'
    version '1.0-SNAPSHOT'
    
    repositories {
        mavenCentral()
    }
    
    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-security'
        implementation 'org.springframework.boot:spring-boot-starter-web'
    }
    
    test {
        useJUnitPlatform()
    }

    spring-boot-starter-security, spring-boot-starter-web 를 추가한다. 둘 다 2.6.4 버전이다.

    2. 프로젝트 구성

    아래와 같이 구성한다.

    org.example.tistorysecuritypreauthsample
    +- config
    |  +- auth
    |  |  +- SystemAuthenticationFilter.java   // System 요청 filter
    |  |  +- SystemAuthenticationProvider.java // SystemAuthenticationFilter 의 Provider
    |  |  +- UserAuthenticationFilter.java     // User 요청 filter
    |  |  +- UserAuthenticationProvider.java   // UserAuthenticationFilter 의 Provider
    |  +- SecurityConfig.java                  // spring security config
    +- controller
    |  +- SampleController.java                // 테스트 용 controller
    +- Application.java

    Application.java 에는 main 메소드가 있다.

    package org.example.tistorysecuritypreauthsample;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    
    @SpringBootApplication
    public class Application {
    
      public static void main(String[] args) {
        SpringApplication.run(Application.class);
      }
    }

    3. 설정

    3-1. SecurityConfig.java

    먼저 spring security 를 사용하도록 한다. 이를 위해 SecurityConfig.java 를 작성한다.

    package org.example.tistorysecuritypreauthsample.config;
    
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
    
    @Configuration
    @EnableGlobalMethodSecurity(prePostEnabled = true)
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
      @Override
      protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
      }
    }

    WebSecurityConfigurerAdapter 를 상속하여 구현한다.

    • PreAuthorize 를 사용하기 위해 @EnableGlobalMethodSecurity(prePostEnabled = true) 를 추가한다.
    • api 서버로 사용할 것이기 때문에 csrf 를 비활성화한다.

    3-2. SystemAuthenticationFilter.java

    시스템 요청을 위한 필터 SystemAuthenticationFilter.java 를 작성한다.

    package org.example.tistorysecuritypreauthsample.config.auth;
    
    import javax.servlet.http.HttpServletRequest;
    import org.springframework.core.annotation.Order;
    import org.springframework.security.authentication.ProviderManager;
    import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
    import org.springframework.stereotype.Component;
    
    @Order(1)
    @Component
    public class SystemAuthenticationFilter extends AbstractPreAuthenticatedProcessingFilter {
    
      public static final String SYSTEM_AUTH_HEADER = "SYSTEM-AUTH-HEADER";
    
      public SystemAuthenticationFilter(SystemAuthenticationProvider systemAuthenticationProvider) {
        super.setCheckForPrincipalChanges(true);
        super.setAuthenticationManager(new ProviderManager(systemAuthenticationProvider));
      }
    
      @Override
      protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
        return request.getHeader(SYSTEM_AUTH_HEADER);
      }
    
      @Override
      protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
        return "";
      }
    }

    AbstractPreAuthenticatedProcessingFilter 를 상속하여 필터를 만든다. HttpServletRequest 에서 헤더 정보를 추출한다.

    • @Order(1): 이 필터가 가장 먼저 나와야 하기 때문에 값을 주어 순서를 고정한다.
    • Authentication 을 전달할 Provider 를 주입해준다. 이 Provider 는 3-3 에서 설명한다.
    • setCheckForPrincipalChanges(true): true 로 해주어야 이 필터가 사용된다.
      • boot 에서 자동으로 추가하는 filter 를 타고 default Authentication 이 전달되는데, 여기서는 그것을 사용하지 않고, 서비스에 필요한 Authentication 로 갱신해야 하기 때문에 true 로 만든다.
    • getPreAuthenticatedPrincipal: 여기서 헤더에 있는 값을 추출해 전달한다. SYSTEM-AUTH-HEADER 헤더가 있으면 그 값을, 없으면 null 을 전달한다.
    • getPreAuthenticatedCredentials: 이 예시에서는 credential 을 사용하지 않는다.

    3-3. SystemAuthenticationProvider.java

    Authentication 을 시큐리티 컨텍스트로 전달할 Provider 이다. 위의 SystemAuthenticationFilter 에서 이 Provider 를 사용한다.

    package org.example.tistorysecuritypreauthsample.config.auth;
    
    import java.util.Collections;
    import java.util.List;
    import java.util.Map;
    import org.springframework.security.authentication.AuthenticationProvider;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.AuthenticationException;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
    import org.springframework.stereotype.Component;
    
    @Component
    public class SystemAuthenticationProvider implements AuthenticationProvider {
    
      private static final String SECRET_KEY = "password";
    
      @Override
      public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String secretKey = (String) authentication.getPrincipal();
    
        if (secretKey == null || !secretKey.equals(SECRET_KEY)) {
          return null;
        }
    
        Map<String, Object> userInfo = Map.of("id", "system");
        List<SimpleGrantedAuthority> roles = Collections.singletonList(
            new SimpleGrantedAuthority("ROLE_SYSTEM"));
        return new PreAuthenticatedAuthenticationToken(userInfo, null, roles);
      }
    
      @Override
      public boolean supports(Class<?> authentication) {
        return PreAuthenticatedAuthenticationToken.class.isAssignableFrom(authentication);
      }
    }
    • authenticate:
      • AbstractPreAuthenticatedProcessingFilter 의 getPreAuthenticatedPrincipal() 메소드를 통해 전달되는 값을 여기서 getPrincipal() 를 통해 받는다.
      • 이 값을 미리 정해진 값 (위에서는 "password") 과 동일한지 판단한다.
      • 사용자 정보를 map 으로 만든다. {id=system}
      • 사용자 권한(role) 을 List<SimpleGrantedAuthority> 로 만든다. [ROLE_SYSTEM]
      • PreAuthenticatedAuthenticationToken 으로 만들어 전달한다.
    • supports:
      • AbstractPreAuthenticatedProcessingFilter 사용하는 경우, PreAuthenticatedAuthenticationToken 을 사용하고 있기 때문에, Provider 에서도 이것을 허용하도록 한다.

    3-4. UserAuthenticationFilter.java

    인증된 사용자를 위한 필터 UserAuthenticationFilter.java 를 작성한다.

    package org.example.tistorysecuritypreauthsample.config.auth;
    
    import java.util.Collections;
    import java.util.List;
    import java.util.Map;
    import javax.servlet.http.HttpServletRequest;
    import org.springframework.core.annotation.Order;
    import org.springframework.security.authentication.ProviderManager;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
    import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
    import org.springframework.stereotype.Component;
    import org.springframework.util.ObjectUtils;
    
    @Order(2)
    @Component
    public class UserAuthenticationFilter extends AbstractPreAuthenticatedProcessingFilter {
    
      public static final String USER_ID_HEADER = "USER-ID-HEADER";
      public static final String USER_ROLES_HEADER = "USER-ROLES-HEADER";
    
      public UserAuthenticationFilter(UserAuthenticationProvider userAuthenticationProvider) {
        super.setCheckForPrincipalChanges(true);
        super.setAuthenticationManager(new ProviderManager(userAuthenticationProvider));
      }
    
      @Override
      protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
        String userId = request.getHeader(USER_ID_HEADER);
        List<String> roles = Collections.list(request.getHeaders(USER_ROLES_HEADER));
    
        if (ObjectUtils.isEmpty(userId) || ObjectUtils.isEmpty(roles)) {
          return null;
        }
    
        return Map.of(
            "userId", userId,
            "roles", roles);
      }
    
      @Override
      protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
        return "";
      }
    
      @Override
      protected boolean principalChanged(HttpServletRequest request,
          Authentication currentAuthentication) {
    
        if (currentAuthentication instanceof PreAuthenticatedAuthenticationToken) {
          return false;
        }
    
        return super.principalChanged(request, currentAuthentication);
      }
    }

    AbstractPreAuthenticatedProcessingFilter 를 상속하여 필터를 만든다. HttpServletRequest 에서 헤더 정보를 추출한다.

    • @Order(2): 이 필터가 두번째로 나와야 하기 때문에 값을 주어 순서를 고정한다.
    • Authentication 을 전달할 Provider 를 주입해준다. 이 Provider 는 3-5 에서 설명한다.
    • setCheckForPrincipalChanges(true): true 로 해주어야 이 필터가 사용된다.
      • boot 에서 자동으로 추가하는 filter 를 타고 default Authentication 이 전달되는데, 여기서는 그것을 사용하지 않고, 서비스에 필요한 Authentication 로 갱신해야 하기 때문에 true 로 만든다.
    • getPreAuthenticatedPrincipal: 여기서 헤더에 있는 사용자 정보값을 추출한다.
      • USER-ID-HEADER 헤더에서 사용자 ID 를, USER-ROLES-HEADER 에서 사용자 권한을 추출한다. (권한은 여러개가 가능하다.) 이 값을 Map 타입으로 반환하여 Provider 에서 받을 수 있도록 한다.
    • getPreAuthenticatedCredentials: 이 예시에서는 credential 을 사용하지 않는다.
    • principalChanged: 이 필터 전에 SystemAuthenticationFilter 를 먼저 통과하는데, 앞 필터에서 적절한 system Authentication 이 할당되었다면 무시하고 통과시켜야 한다. 그래서 먼저 만들어진 PreAuthenticatedAuthenticationToken 이 있는지 확인하고 있다면 false 를 반환하여 처리하지 않도록 한다.

    3-5. UserAuthenticationProvider.java

    Authentication 을 시큐리티 컨텍스트로 전달할 Provider 이다. 위의 UserAuthenticationFilter 에서 이 Provider 를 사용한다.

    package org.example.tistorysecuritypreauthsample.config.auth;
    
    import java.util.List;
    import java.util.Map;
    import java.util.stream.Collectors;
    import org.springframework.security.authentication.AuthenticationProvider;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.AuthenticationException;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
    import org.springframework.stereotype.Component;
    
    @Component
    public class UserAuthenticationProvider implements AuthenticationProvider {
    
      @Override
      public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Map<String, Object> principal = (Map<String, Object>) authentication.getPrincipal();
    
        String userId = (String) principal.get("userId");
        List<String> roles = (List<String>) principal.get("roles");
    
        Map<String, Object> userInfo = Map.of("id", userId);
        List<SimpleGrantedAuthority> authorities = roles.stream()
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
            .collect(Collectors.toList());
        return new PreAuthenticatedAuthenticationToken(userInfo, null, authorities);
      }
    
      @Override
      public boolean supports(Class<?> authentication) {
        return PreAuthenticatedAuthenticationToken.class.isAssignableFrom(authentication);
      }
    }
    • authenticate:
      • AbstractPreAuthenticatedProcessingFilter 의 getPreAuthenticatedPrincipal() 메소드를 통해 전달되는 값을 여기서 authentication.getPrincipal() 를 통해 받는다. (필터에서 Map 타입으로 전달되어서 Map 으로 받는다.)
      • 사용자 ID 를 userId 로 받고, 권한을 roles 를 받는다.
      • 사용자 정보를 map 으로 만든다. {id=userId}
      • 사용자 권한(role) 을 List<SimpleGrantedAuthority> 로 만든다. [ROLE_*] (ex: ROLE_USER, ROLE_ADMIN ...)
      • PreAuthenticatedAuthenticationToken 으로 만들어 전달한다.
    • supports:
      • AbstractPreAuthenticatedProcessingFilter 사용하는 경우, PreAuthenticatedAuthenticationToken 을 사용하고 있기 때문에, Provider 에서도 이것을 허용하도록 한다.

    4. Controller

    테스트를 할 controller 를 작성한다.

    package org.example.tistorysecuritypreauthsample.controller;
    
    import java.util.Collection;
    import java.util.Map;
    import org.springframework.security.access.prepost.PreAuthorize;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    public class SampleController {
    
      @GetMapping("/sample")
      public String sample(Authentication authentication) {
        print(authentication);
        return "SUCCESS";
      }
    
      @PreAuthorize("hasRole('USER')")
      @GetMapping("/sample-user")
      public String sampleUser(Authentication authentication) {
        print(authentication);
        return "SUCCESS";
      }
    
      @PreAuthorize("hasRole('ADMIN')")
      @GetMapping("/sample-admin")
      public String sampleAdmin(Authentication authentication) {
        print(authentication);
        return "SUCCESS";
      }
    
      @PreAuthorize("hasRole('SYSTEM')")
      @GetMapping("sample-system")
      public String sampleSystem(Authentication authentication) {
        print(authentication);
        return "SUCCESS";
      }
    
      private void print(Authentication authentication) {
        if (authentication != null) {
          Map<?, ?> info = (Map<?, ?>) authentication.getPrincipal(); // @AuthenticationPrincipal
          Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
          System.out.println("ID=" + info.get("id") + ", ROLE=" + authorities.toString());
        } else {
          System.out.println("unauthenticated");
        }
      }
    }

    시큐리티 컨텍스트에서 Authentication 을 가져오는 것은 여러 방법이 있지만 여기서는 메소드의 파라미터로 받아오는 방식을 택했다.

    Provider 에서 전달한 Principal 은 @AuthenticationPrincipal 어노테이션을 통해서도 받아올 수 있지만 여기서는 authentication 에서 가져온다. Provider 에서 Map 타입으로 전달되었기 때문에 Map 타입으로 받아온다.

    5. 테스트

    16 개의 경우의 수 가 있는데, 여기서는 일부만 테스트 해 본다. 전체 경우의 수에 대한 테스트는 https://github.com/bitgadak/tistory-spring-security-preauth-sample/blob/master/src/test/java/org/example/tistorysecuritypreauthsample/ApplicationTest.java 에서 확인 가능하다.

    5-1. 권한 없는 요청 + 제한없는 메소드: 호출 가능

    $ curl http://localhost:8080/sample --silent
    SUCCESS
    JAVA 로그:
    unauthenticated

    5-2. 권한 없는 요청 + USER 권한 메소드: 호출 불가

    $ curl http://localhost:8080/sample-user --silent
    {"timestamp":"2022-03-15T07:31:12.306+00:00","status":403,"error":"Forbidden","path":"/sample-user"}
    JAVA 로그:
    unauthenticated

    5-3. USER 권한 요청 + USER 권한 메소드: 호출 가능

    $ curl http://localhost:8080/sample-user -H 'USER-ID-HEADER: tester' -H 'USER-ROLES-HEADER: USER' --silent
    SUCCESS
    JAVA 로그:
    ID=tester, ROLE=[ROLE_USER]

    5-4. USER 권한 요청 + ADMIN 권한 메소드: 호출 불가

    $ curl http://localhost:8080/sample-admin -H 'USER-ID-HEADER: tester' -H 'USER-ROLES-HEADER: USER' --silent
    {"timestamp":"2022-03-15T07:34:30.166+00:00","status":403,"error":"Forbidden","path":"/sample-admin"}
    JAVA 로그:

    5-5. ADMIN 권한 요청 + USER 권한 메소드: 호출 가능

    $ curl http://localhost:8080/sample-user -H 'USER-ID-HEADER: admin' -H 'USER-ROLES-HEADER: USER' -H 'USER-ROLES-HEADER: ADMIN' --silent
    SUCCESS
    JAVA 로그:
    ID=admin, ROLE=[ROLE_USER, ROLE_ADMIN]

    5-6. ADMIN 권한 요청 + ADMIN 권한 메소드: 호출 가능

    $ curl http://localhost:8080/sample-admin -H 'USER-ID-HEADER: admin' -H 'USER-ROLES-HEADER: USER' -H 'USER-ROLES-HEADER: ADMIN' --silent
    SUCCESS
    JAVA 로그:
    ID=admin, ROLE=[ROLE_USER, ROLE_ADMIN]

    5-7. SYSTEM 권한 요청 + SYSTEM 권한 메소드: 호출 가능

    $ curl http://localhost:8080/sample-system -H 'SYSTEM-AUTH-HEADER: password' --silent
    SUCCESS
    JAVA 로그:
    ID=system, ROLE=[ROLE_SYSTEM]

    잘 된다.


    모든 코드는 여기서 확인할 수 있다: https://github.com/bitgadak/tistory-spring-security-preauth-sample

    반응형

    댓글

Designed by Tistory.