-
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
반응형