인프런 스프링 시큐리티 강의 학습-16
인가 프로세스 DB 연동 구현 인가 개요, 웹 기반 인가처리 DB 연동 를 정리한 포스트입니다.
출처는 인프런의 스프링 시큐리티 - Spring Boot 기반으로 개발하는 Spring Security강의를 바탕으로 이 포스트를 작성하고 있습니다.
강의의 세션 5의 2,3,4,5번 강의내용에 대한 정리입니다.
스프링 시큐리티 인가 개요
지금까지 여러 권한 설정을 SecurityConfig에 작성해 구현해 왔는데 그것을 DB와 연동해 자원 및 권한을 설정하고 제어함으로 동적 관리가 가능하도록 할 것입니다.
- 관리자 시스템을 구축해 회원 관리, 권한 관리, 자원 관리가 가능하게 할 것입니다.
- 회원 관리: 사용자의 권한 부여
- 권한 관리: 사이트가 에서 사용자가 가질 수 있는 권한 생성 및 삭제
- 자원 관리: 각 서버 자원의 생성, 삭제, 수정, 매핑 권한 계층 구현에는 URL 방식과 Method 방식이 있습니다. 2개 다 강의에는 나와있고, 코드는 다 작성해 보기는 했지만, 포스트에는 URL 위주로 작성하고, Method는 간단하게 살펴보겠습니다.
그리고 포스트 14번까지와 15번의 코드가 많이 다릅니다. 그러니 이 코드는 제 깃허브 주소를 참고하시면 됩니다.
제작 과정에서 DB로 완전히 넘어가는 도중에 코드가 조금 꼬여 위 출처의 분기점은 제대로 동작이 안될 수 있습니다. 만약 동작하는 분기점을 확인하고 싶으시다면 이 링크를 참고해 주시기 바랍니다.
웹 기반 인가처리 DB 연동
주요 아키텍처 이해
스프링 시큐리티가 인가처리를 하기 위해 보통 코드를 http.antMatchers("/user").access("hasRole('USER')")
과 같이 프로그램을 작성합니다.
위 코드가 나타내는 바는 사용자가 /user 경로로 접근하기 위해서는 USER라는 권한을 가지고 있어야 한다는 것을 의미합니다.
그럼 인가 처리를 담당하는 필터인 FilterSecurityInterceptor 가 인가처리를 위임하는 클래스인 AccessDecisionManager에게 인증 정보, 요청 정보, 권한 정보를 전달 하고, 메니저가 인가처리를 합니다.
그럼 어떻게 그 값을 얻어 전달할 지 확인해 보겠습니다.
- 사용자가 인증을 요청하면, 시큐리티 보안 필터인 SecurityInterceptor가 요청을 받고, 인증, 요청, 권한 정보를 얻습니다.
- 인증 정보는 SecurityContext안에 있으므로 필터가 SecurityContext를 참조해 정보를 얻습니다.
- 요청 정보는 직접 FilterInvocation 객체를 생성해서 request 정보를 저장한 후 객체를 전달합니다.
- 권한 정보는 설정한 정보를 읽어 경로를 Key로 권한 정보를 Value로 매핑되어 Map 객체에 저장되어 필터에
List<configAttribute>
로 hasRole(“USER”)같은 value를 저장해 전달합니다.
- SecurityInterceptor가 얻은 정보를 AccessDecisionManager에게 전달합니다.
ExpressionBasedFilterInvocationSecurityMetadataSource에서 저장된 Map 객체 위 사진은 FilterInvocationSecurityMetadataSource의 구현체 중 하나인 ExpressionBasedFilterInvocationSecurityMetadataSource 클레스에서 전달된 인가 정보 가 key와 value의 map 객체로 저장되 있는 것을 확인하실 수 있습니다.
그 후 위 클래스에서 부모 클래스인 DefaultFilterInvocationSecurityMetadataSource에게 Map 객체를 전달해 그곳에서 requestMap 객체에 저장해 놓습니다.
FilterSecurityInterceptor가 invoke(new FilterInvocation(request, response, chain));
를 이용해 FilterInvocation 객체를 생성해 request 객체를 담아 요청 정보를 저장합니다.
그 후 AbstractSecurityInterceptor에서 Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);
를 통해 권한 정보를 추출합니다. DefaultFilterInvocationSecurityMetadataSource가 이미 초기화 시 requestMap을 저장해 놨기 때문에 관련 메소드를 불러 가져오는 것입니다. 그리고 코드를 확인해 보면 인가 처리가 정상적으로 되지 않는 것을 확인하실 수 있습니다. 그 이유는 이미 다 작성이 된 코드기 때문에 UrlFilterInvocationSecurityMetadataSource가 작동하고, DB 설정을 다 하지 않았기 때문에 등록한 requestMap.put(new AntPathRequestMatcher("/mypage"), List.of(new SecurityConfig("ROLE_USER")));
만 작동합니다.
AbstractSecurityInterceptor에서 인증 정보인 authentication 또한 Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
코드를 사용해 securitycontext에서 가져옵니다.
결국 this.accessDecisionManager.decide(authenticated, object, attributes);
를 확인하면 인증, 요청, 권한 정보 모두를 가져와 accessDecisionManager에게 전달합니다.
FilterInvocationSecurityMetadataSource - 1
설명
FilterInvocationSecurityMetadataSource는 SecurityMetadataSource를 상속한 Url 기반 인가처리 인터페이스입니다.
- 사용자가 접근을 원하는 URL 자원에 대해 권한 정보 추출합니다.
- AccessDecisionManager에게 추출한 권한 정보를 전달해 인가처리를 수행합니다.
- DB로부터 자원 및 권한 정보를 매핑해 맵으로 관리합니다.
- 사용자의 요청마다 요청정보에 매핑된 권한 정보를 확인합니다.
인가 처리의 순서도
- 사용자가 특정 자원에 접근하고자 하면 그 요청을 FilterSecurityInterceptor가 받습니다.
- 그럼 Interceptor는 권한정보를 추출하기 위해 FilterInvocationSecurityMetadataSource 구현체를 호출하게 됩니다.
- 그럼 이 구현체는 내부적으로 requestMap 객체를 관리합니다. 이 객체는 /admin - ROLE_ADMIN 과 같은 맵 형태의 Key(URL) - Value(필요 권한 정보) 형태로 인가 정보가 저장되어 있습니다.
- 이 값들은 DB에서 얻어와 맵 형태로 저장하게 됩니다.
- 만약 권한목록이 존재하면 그 값을 AccessDecisionManager에게 전달합니다.
실제 코드
UrlFilterInvocationSecurityMetadataSource.java
public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
private final LinkedHashMap<RequestMatcher, List<ConfigAttribute>> requestMap;
private SecurityResourceService securityResourceService;
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
HttpServletRequest request = ((FilterInvocation) object).getRequest(); // 1
requestMap.put(new AntPathRequestMatcher("/mypage"), List.of(new SecurityConfig("ROLE_USER"))); // 4
if(requestMap != null) {
for(Map.Entry<RequestMatcher, List<ConfigAttribute>> entry : requestMap.entrySet()) {
RequestMatcher matcher = entry.getKey(); // 2
if(matcher.matches(request)) { // 2
return entry.getValue(); // 2
}
}
}
return null;
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
Set<ConfigAttribute> allAttributes = new HashSet<>();
for (Map.Entry<RequestMatcher, List<ConfigAttribute>> entry : requestMap.entrySet()) {
allAttributes.addAll(entry.getValue());
}
return allAttributes;
}
@Override
public boolean supports(Class<?> clazz) {
return FilterInvocation.class.isAssignableFrom(clazz); // 3
}
public void reload() {
LinkedHashMap<RequestMatcher, List<ConfigAttribute>> reloadedMap = securityResourceService.getResourceList();
Iterator<Map.Entry<RequestMatcher, List<ConfigAttribute>>> iterator = reloadedMap.entrySet().iterator();
requestMap.clear();
while (iterator.hasNext()) {
Map.Entry<RequestMatcher, List<ConfigAttribute>> entry = iterator.next();
requestMap.put(entry.getKey(), entry.getValue());
}
}
}
- 사용자가 어떤 URL로 요청했는지 요청 정보를 가져옵니다.
- DB의 요청 정보를 가져와 matcher에 저장하고, 그 값을 사용자의 요청 정보와 비교해 만약 일치한다면 해당 요청 정보에 대한 권한 정보를 Return합니다.
- URL 방식인지 Method 방식인지 요청 타입을 확인하는 코드입니다.
- 필터가 변경되었지만 DB 설정이 완료되지 않았기 때문에 인가 설정이 잘 되었는지 확인하기 위해 임의로 값을 넣었습니다.
SecurityConfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 아래 코드까지의 코드는 동일합니다.
http
.authorizeRequests() // 1
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/login_proc")
.authenticationDetailsSource(formWebAuthenticationDetailsSource)
.successHandler(formAuthenticationSuccessHandler)
.failureHandler(formAuthenticationFailureHandler)
.permitAll()
.and()
.exceptionHandling()
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))
.accessDeniedPage("/denied")
.accessDeniedHandler(accessDeniedHandler())
.and()
.addFilterBefore(customFilterSecurityInterceptor(), FilterSecurityInterceptor.class);
http.csrf().disable();
return http.build();
}
@Bean
public FilterSecurityInterceptor customFilterSecurityInterceptor() throws Exception {
FilterSecurityInterceptor filterSecurityInterceptor = new FilterSecurityInterceptor();
filterSecurityInterceptor.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource()); // 2
filterSecurityInterceptor.setAccessDecisionManager(affirmativeBased()); // 3
filterSecurityInterceptor.setAuthenticationManager(authenticationManager(authenticationConfiguration)); // 4
return filterSecurityInterceptor;
}
private AccessDecisionManager affirmativeBased() { // 3
return new AffirmativeBased(getAccessDecisionVoters());
}
private List<AccessDecisionVoter<?>> getAccessDecisionVoters() { // 3
return List.of(new RoleVoter());
}
@Bean
public FilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource() throws Exception { // 5
return new UrlFilterInvocationSecurityMetadataSource(urlResourcesMapFactoryBean().getObject(), securityResourceService);
}
- 원래 지정했던 모든 인가 처리들은 필터를 추가함에 따라 이제 작동하지 않아 삭제했습니다.
- 위에 만든 구현체가 정상적으로 등록되서 동작할 수 있도록 Interceptor에 등록해 줍니다.
- 접근 결정 관리자 3개 중 가장 보편적으로 많이 사용하는 affirmativeBased를 사용하도록 설정해줍니다.
- 인가 처리 전 인증 여부를 검사하기 때문에 인증 관리자를 설정해 줘야 합니다.
- MetadataSource를 Bean에 추가합니다.
실제 실행 화면
FilterChainProxy에 추가된 FilterSecurityInterceptor FilterChainProxy에 FilterSecurityInterceptor가 2개 있는 것을 확인할 수 있습니다. 원래 있던 필터와 추가한 필터입니다. 즉 정상적으로 필터 등록이 완료된 것을 확인할 수 있습니다.
Metadata에 저장되어 있는 맵 객체 생성한 FilterSecurityInterceptor 안에 생성한 메타 데이터인 UrlFilterInvocationSecurityMetadataSource가 정상적으로 등록이 되어 있고, 맵 객체의 값이 정상적으로 저장되어 있는 것을 확인할 수 있습니다.
requestMap에 권한 목록을 추가한 뒤 조건문의 작동 requestMap에 권한 정보를 추가한 후 실제 조건문이 작동함으로써 정상적으로 클라이언트 요청을 Match하는 것을 알 수 있습니다.
FilterInvocationSecurityMetadataSource - 2
DB에 저장된 권한/자원 정보를 얻어 requestMap를 빈으로 생성해 UrlFilterInvocationSecurityMetadataSource에 전달해야 하는데 그 역할을 하는 것이 UrlResourcesMapFactoryBean 입니다.
실제 코드
UrlResourcesMapFactoryBean.java
public class UrlResourcesMapFactoryBean implements FactoryBean<LinkedHashMap<RequestMatcher, List<ConfigAttribute>>> {
private SecurityResourceService securityResourceService; // 1
private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> resourceMap; // 2
public void setSecurityResourceService(SecurityResourceService securityResourceService) {
this.securityResourceService = securityResourceService;
}
@Override
public LinkedHashMap<RequestMatcher, List<ConfigAttribute>> getObject() { // 3
if (resourceMap == null) {
init();
}
return resourceMap;
}
private void init() { // 3
resourceMap = securityResourceService.getResourceList();
}
@Override
public Class<?> getObjectType() {
return LinkedHashMap.class;
}
@Override
public boolean isSingleton() { // 4
return FactoryBean.super.isSingleton();
}
}
- DB로 부터 가져온 정보를 Mapping하는 Service를 불러옵니다.
- 권한/자원 정보를 Bean으로 만들 객체를 생성합니다.
- DB로부터 가져온 정보가 Mapping된 Map을 이 Bean 클래스로 가져와 저장 합니다.
- 메모리에 1개만 존재하도록 설정합니다.
SecurityResourceService.java
public class SecurityResourceService {
private final ResourcesRepository resourcesRepository; // 1
public SecurityResourceService(ResourcesRepository resourcesRepository) {
this.resourcesRepository = resourcesRepository;
}
public LinkedHashMap<RequestMatcher, List<ConfigAttribute>> getResourceList() {
LinkedHashMap<RequestMatcher, List<ConfigAttribute>> result = new LinkedHashMap<>();
List<Resources> resourcesList = resourcesRepository.findAllResources(); // 1
resourcesList.forEach(re -> { // 4
List<ConfigAttribute> configAttributeList = new ArrayList<>();
re.getRoleSet().forEach(role -> {
configAttributeList.add(new SecurityConfig(role.getRoleName())); // 2
});
result.put(new AntPathRequestMatcher(re.getResourceName()), configAttributeList); // 3
});
return result;
}
}
DB의 인가 정보를 Mapping하는 클래스입니다.
- DB로 부터 권한/자원 정보를 가져와 저장합니다.
- 자원 1개 마다 여러개의 권한 정보가 담깁니다.
- 완성된 정보를 return하기 위해 result에 생성된 값을 담습니다.
- 즉 요약하면 아래와 같이 됩니다.
- DB에 모든 자원의 자원을 가지고 옵니다. 그럼 자원 1개마다 권한 정보가 여러개인 1:N 형태로 담겨서 가지고 옵니다.
- 그럼 첫 forEach를 통해 자원 1개를 돌 때 마다 그 자원 정보와 매핑된 권한 정보를 리스트로 담아 자원과 권한이 1:N 형식으로 result에 들어가 모든 자원 정보의 처리가 끝나면 return 하게 됩니다.
ResourcesRepository.java
public interface ResourcesRepository extends JpaRepository<Resources, Long> {
Resources findByResourceNameAndHttpMethod(String resourceName, String httpMethod);
@Query("select r from Resources r join fetch r.roleSet where r.resourceType = 'url' order by r.orderNum desc")
List<Resources> findAllResources();
}
jpa 문법을 사용해 권한/자원 정보를 가지고 옵니다. 주의해야 할 점은 이 매핑 작업도 작은게 앞으로 오고 큰 자원 정보가 뒤에 가야 정상적으로 작동하기 때문에 orderNum 순으로 정렬해 그 값을 받아와 저장할 때 순서대로 저장하지 않는 HashMap이 아닌 순서에 맞게 저장하는 LinkedHashMap으로 저장합니다.
AppConfig.java
@Configuration
class AppConfig {
@Bean
public SecurityResourceService securityResourceService(ResourcesRepository resourcesRepository) { // 1
return new SecurityResourceService(resourcesRepository);
}
}
일반적으로 사용하는 설정 클래스입니다.
- SecurityResourceService를 Bean에 등록하고, resourceRepository를 전달해 줍니다.
SecurityConfig.java
@Bean
public FilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource() throws Exception {
return new UrlFilterInvocationSecurityMetadataSource(urlResourcesMapFactoryBean().getObject(), securityResourceService);
}
private UrlResourcesMapFactoryBean urlResourcesMapFactoryBean() {
UrlResourcesMapFactoryBean urlResourcesMapFactoryBean = new UrlResourcesMapFactoryBean();
urlResourcesMapFactoryBean.setSecurityResourceService(securityResourceService);
return urlResourcesMapFactoryBean;
}
DB로부터 받은 ResourceMap을 FilterInvocationSecurityMetadataSource에게 전달합니다.
UrlFilterInvocationSecurityMetadataSource.java
public UrlFilterInvocationSecurityMetadataSource(LinkedHashMap<RequestMatcher, List<ConfigAttribute>> resourcesMap, SecurityResourceService securityResourceService) {
this.requestMap = resourcesMap;
this.securityResourceService = securityResourceService;
}
위에서 전달한 데이터를 받기 위해 생성자를 추가했습니다.
실제 실행 장면
DB에 저장된 자원/권한 정보를 가저옴 DB에 저장되어 있는 자원/권한 정보가 List에 정상적으로 저장되었고, 아래에 보시면 result에도 정상적으로 매핑 된 것을 확인할 수 있습니다.
가져온 정보를 매핑해 저장함