이 시리즈에 나오는 모든 내용은 인프런 인터넷 강의 - 스프링 시큐리티 - Spring Boot 기반으로 개발하는 Spring Security - 에서 기반된 것입니다. 그리고 여기서 인용되는 PPT 이미지 또한 모두 해당 강의에서 가져왔음을 알립니다. 추가적으로 여기에 작성된 코드들 또한 해당 강의의 github 에 올라와 있는 코드를 참고해서 만든 겁니다.
이 글의 시작 코드는 아래와 같이 준비한다.
1. git clone https://github.com/onjsdnjs/corespringsecurity.git
2. git checkout ch05-01
3. application.properties 파일 내용 일부 수정spring.datasource.url=jdbc:postgresql://localhost:5432/springboot spring.datasource.username=postgres spring.datasource.password=root # 자기 것에 맞게 수정 spring.jpa.hibernate.ddl-auto=create # 일단 create 으로 수정 spring.jpa.properties.hibernate.format_sql=true spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true spring.thymeleaf.cache=false spring.devtools.livereload.enabled=true spring.devtools.restart.enabled=true spring.main.allow-bean-definition-overriding=true
- SetupDataLoader 코드 일부 수정
@Component public class SetupDataLoader implements ApplicationListener<ContextRefreshedEvent> { // ... 일부 생략 ... private void setupSecurityResources() { Set<Role> roles = new HashSet<>(); Role adminRole = createRoleIfNotFound("ROLE_ADMIN", "관리자"); roles.add(adminRole); createResourceIfNotFound("/admin/**", "", roles, "url"); Account account = createUserIfNotFound("admin", "pass", "admin@gmail.com", 10, roles); Set<Role> roles1 = new HashSet<>(); Role managerRole = createRoleIfNotFound("ROLE_MANAGER", "매니저"); roles1.add(managerRole); createUserIfNotFound("manager", "pass", "manager@gmail.com", 20, roles1); Set<Role> roles3 = new HashSet<>(); Role childRole1 = createRoleIfNotFound("ROLE_USER", "회원"); roles3.add(childRole1); createResourceIfNotFound("/users/**", "", roles3, "url"); createUserIfNotFound("user", "pass", "user@gmail.com", 30, roles3); createResourceIfNotFound("/mypage", "", roles3, "url"); createResourceIfNotFound("/message", "", roles1, "url"); createResourceIfNotFound("/config", "", roles, "url"); } }
"두 번" 애플리케이션 실행, 처음은 에러 문구 좀 보이고, 두번째 다시 실행하면 OK
application.properties 에서
spring.jpa.hibernate.ddl-auto=validate
로 수정이전 글에서 작성한 코드에 이어서 작성하고 싶지만, 강의 코드 자체가 부분적으로 아주 많이 바뀌었는데, 그걸 다 추적해서 새로 작성하는 건 힘들어서 그냥 clone, checkout 했다.
목표
==>
동적 권한 관리가 가능!antMathcers("/user").hasRole("USER")
==>
권한 부여==>
권한 생성, 삭제==>
자원 생성, 삭제, 수정, 권한 매핑URL
or Method(=aop 방식)
권한과 관련된 시스템을 구축하기 위해서는 테이블과 그에 따른 도메인,
그리고 그 도메인과 연관이 있는 JPA Entity Class를 작성해야 한다.
우리가 필요로하는 모델은 아래와 같다.
package io.security.corespringsecurity.domain.entity;
import lombok.*;
import javax.persistence.*;
import java.io.Serializable;
import java.util.HashSet;
import java.util.Set;
@Entity
@Data
@ToString(exclude = {"userRoles"})
@Builder
@EqualsAndHashCode(of = "id")
@NoArgsConstructor
@AllArgsConstructor
public class Account implements Serializable {
@Id
@GeneratedValue
private Long id;
@Column
private String username;
@Column
private String email;
@Column
private int age;
@Column
private String password;
@ManyToMany(fetch = FetchType.LAZY, cascade={CascadeType.ALL})
@JoinTable(name = "account_roles",
joinColumns = { @JoinColumn(name = "account_id") },
inverseJoinColumns = {@JoinColumn(name = "role_id") })
private Set<Role> userRoles = new HashSet<>();
}
package io.security.corespringsecurity.domain.entity;
import lombok.*;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import javax.persistence.*;
import java.io.Serializable;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "RESOURCES")
@Data
@ToString(exclude = {"roleSet"})
@EntityListeners(value = { AuditingEntityListener.class })
@EqualsAndHashCode(of = "id")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Resources implements Serializable {
@Id
@GeneratedValue
@Column(name = "resource_id")
private Long id;
@Column(name = "resource_name")
private String resourceName;
@Column(name = "http_method")
private String httpMethod;
@Column(name = "order_num")
private int orderNum;
@Column(name = "resource_type")
private String resourceType;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "role_resources",
joinColumns = { @JoinColumn(name = "resource_id") },
inverseJoinColumns = { @JoinColumn(name = "role_id") })
private Set<Role> roleSet = new HashSet<>();
}
package io.security.corespringsecurity.domain.entity;
import lombok.*;
import javax.persistence.*;
import java.io.Serializable;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;
@Entity
@Table(name = "ROLE")
@Getter
@Setter
@ToString(exclude = {"users","resourcesSet"})
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(of = "id")
public class Role implements Serializable {
@Id
@GeneratedValue
@Column(name = "role_id")
private Long id;
@Column(name = "role_name")
private String roleName;
@Column(name = "role_desc")
private String roleDesc;
@ManyToMany(fetch = FetchType.LAZY, mappedBy = "roleSet")
@OrderBy("ordernum desc")
private Set<Resources> resourcesSet = new LinkedHashSet<>();
@ManyToMany(fetch = FetchType.LAZY, mappedBy = "userRoles")
private Set<Account> accounts = new HashSet<>();
}
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();
@Query("select r from Resources r join fetch r.roleSet " +
"where r.resourceType = 'method' order by r.orderNum desc")
List<Resources> findAllMethodResources();
@Query("select r from Resources r join fetch r.roleSet " +
"where r.resourceType = 'pointcut' order by r.orderNum desc")
List<Resources> findAllPointcutResources();
}
public interface RoleRepository extends JpaRepository<Role, Long> {
Role findByRoleName(String name);
@Override
void delete(Role role);
}
public interface UserRepository extends JpaRepository<Account, Long> {
Account findByUsername(String username);
int countByUsername(String username);
}
우리가 이전에 배운 시큐리티 인가 처리는 위와 같이 코드를 짰다.
그리고 여기서 알 수 있는 게, 시큐리티 필터가 인가 처리를 위해 사용되는 정보가
3가지(인증저보, 요청정보, 권한정보)라는 것이다.
그렇다면 스프링 시큐리티에서 저 정보들을 어떤 식으로 생성해두고 내부적으로 사용할까?
아래 그림을 보면서 설명해보겠다.
일단 처음에는 http.antMatchers("/user").access("hasRole('USER')");
을
RequestMatcher
hasRole("USER")
) 정보를 담는이때 Map 을 만드는 주체는 ExpressionBasedFilterInvocationSecurityMetadataSource
클래스이다. 스프링 시큐리티가 초기화할 때 해당 클래스 내부에서 생성된다.
위 처럼 초기화된 후에 사용자 요청이 들어오면...
FilterSecurityInterceptor
필터는 요청정보를 FilterInvocation
클래스로 Wrap
FilterInvocation
정보를 자신의 부모클래스의 beforeInvocation
메소드의 인자로 주면서 호출
부모 클래스의 beforeInvocation
내에서 MetadataSource
의 참조값을 사용해서 미리 만들었던 Map 정보를 조회
Map 의 Key 값인 RequestMatcher 와 FilterInvocation(현재 요청정보)
을 비교
비교해서 같은 게 있다면 Map value("권한 목록 정보")를 return . (없으면 null 반환)
그 권한 목록이 위 그림에서는 List<ConfigAttribute>
이다.
SecurityInterceptor
는 반환받은 ("권한 목록 정보")와 List<ConfigAttribute>
, Authentication
을 모두 모아서 AccessDecisionManager
에게 보내면서 인가 처리를 위임하게 된다
AccessDecisionManager
은 전달 받은 3개의 인자들을 Voter 에게 주면서 인가처리를 위임한다.
여기서 가장 핵심은 자원의 권한 정보를 생성 및 내장하는 MetadataSource
계열의 클래스다.
만약 우리가 위처럼 http.antMatchers("/user").access("hasRole('USER')");
를 사용하면 ExpressionBasedFilterInvocationSecurityMetadataSource
클래스를 통해서 Map
을 만든다.
하지만 우리가 필요한 것은 저런 하드 코딩된 인가 처리가 아니라 DB 에 있는 자원-인가정보
를 빼와서 해당 정보로 Map 을 생성해두는 것이다. 지금부터 Custom MetadataSource
클래스를 하나 작성해보자.
본격적으로 만들기에 앞서 ExpressionBasedFilterInvocationSecurityMetadataSource
클래스에 디버그 포인트를 잡고 조금 자세히 관찰해보자.
이후에 우리가 직접 만들 때 참고하기 위함이다.
일단 우리의 SecurityConfig 에는 권한 관련 정보를 아래처럼 하드코딩했다.
http
.authorizeRequests()
.antMatchers("/mypage").hasRole("USER")
.antMatchers("/messages").hasRole("MANAGER")
.antMatchers("config").hasRole("ADMIN")
.antMatchers("/**").permitAll()
.anyRequest().authenticate()
이러면 ExpressionBasedFilterInvocationSecurityMetadataSource
에서 아래와 같이 위에 작성된 url 에 따른 FitlerInvocation 객체를 사용하는 것을 확인할 수 있다. (PermitAll 은 이후에 사용될 것이니 신경쓰지 지금은 신경쓰지 말자.)
이 Map 정보는 부모 클래스인 DefaultFilterInvocationSecurityMetadataSource
내부의 requestMap
필드에 저장된다.
public class DefaultFilterInvocationSecurityMetadataSource implements
FilterInvocationSecurityMetadataSource {
private final Map<RequestMatcher, Collection<ConfigAttribute>> requestMap;
public DefaultFilterInvocationSecurityMetadataSource(
LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> requestMap) {
this.requestMap = requestMap;
}
// ... 일부 생략 ...
}
아무튼 이 정보는 아래와 같이 FilterSecurityInterceptor
필터의 코드에서 사용하게 된다.
실제 인가 처리를 위해서 말이다. 아래 코드를 보자.
- AbstractSecurityInterceptor.java 일부
beforeInvocation
메소드 내부에서 해당 자원에 필요한 권한 정보를 추출하는 것을 확인AccessDecisionManager
에게 권한 심사를 받지 않고 바로 자원에 접근하게 되는 것이다.- DefaultFilterInvocationSecurityMetadataSource.java 일부
FilterInvocation
객체를 받고,이렇게 추출된 권한 정보는 AbstractSecurityInterceptor.beforeInvocation
후반부에 있는 this.accessDecisionManager.decide(authenticated, object, attributes);
메소드에서 사용된다.
첫번째 파라미터는 인증객체, 두번째 파라미터는 FilterInvocation(요청정보)
, 세번째 파라미터는 방금 설명한 자원접근에 필요한 권한 리스트 정보다.
참고로 FilterInvocation 은 FilterSecurityInterceptor 에서 생성하여, 자신의 부모 클래스인
AbstractSecurityInterceptor.beforeInvocation
메소드의 인자로 준 것이다.
최종적으로 accessDecisionManager.decide
메소드에서 Voter 들에 의한 접근 여부를 묻게 된다.
참고: 스프링 시큐리티의 MetadataSource 클래스 및 인터페이스
작성에 앞서 우리가 만들 MetadataSource 클래스가 어디서 어떤 역할을 하는지 그림을 통해 가볍게 보고 바로 구현을 시작하자.
package io.security.corespringsecurity.security.metadatasource;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.security.web.util.matcher.RequestMatcher;
import javax.servlet.http.HttpServletRequest;
import java.util.*;
public class MyUrlFilterInvocationSecurityMetadatsSource implements FilterInvocationSecurityMetadataSource {
private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> requestMap
= new LinkedHashMap<>();
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
HttpServletRequest request = ((FilterInvocation) object).getRequest();
if (requestMap != null) {
Set<Map.Entry<RequestMatcher, List<ConfigAttribute>>> entries = requestMap.entrySet();
for (Map.Entry<RequestMatcher, List<ConfigAttribute>> entry : entries) {
RequestMatcher matcher = entry.getKey();
if (matcher.matches(request)) {
return entry.getValue();
}
}
}
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);
}
}
@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// ... 생략 ....
// AcessDecisionManager 생성
private AccessDecisionManager affirmativeBased() {
AffirmativeBased affirmativeBased = new AffirmativeBased(getAccessDecistionVoters());
return affirmativeBased;
}
// AcessDecisionManager 내부에서 찬반 여부를 정하는 Voter 생성
private List<AccessDecisionVoter<?>> getAccessDecistionVoters() {
return Arrays.asList(new RoleVoter());
}
// 자원에 대한 권한 정보를 읽는 MetadataSource 생성
@Bean
public FilterInvocationSecurityMetadataSource myUrlFilterInvocationSecurityMetadataSource() {
return new MyUrlFilterInvocationSecurityMetadatsSource();
}
// MetadataSource 를 사용하는 권한 체크 필터,
// 즉 FilterSecurityInterceptor 를 직접 생성한다.
@Bean
public FilterSecurityInterceptor customFilterSecurityInterceptor() throws Exception {
FilterSecurityInterceptor filterSecurityInterceptor = new FilterSecurityInterceptor();
filterSecurityInterceptor
.setSecurityMetadataSource(myUrlFilterInvocationSecurityMetadataSource());
filterSecurityInterceptor.setAccessDecisionManager(affirmativeBased());
// 권한체크 필터는 현재 사용자가 인증된 사용자인지 검사하기 때문에 인증 매니저가 필요하다.
filterSecurityInterceptor.setAuthenticationManager(authenticationManagerBean());
return filterSecurityInterceptor;
}
@Override
protected void configure(final HttpSecurity http) throws Exception {
http
.authorizeRequests()
// .antMatchers("/mypage").hasRole("USER")
// .antMatchers("/messages").hasRole("MANAGER")
// .antMatchers("/config").hasRole("ADMIN")
// .antMatchers("/**").permitAll()
.anyRequest().authenticated()
.and()
// ... 일부 생략 ...
.and()
.addFilterBefore(customFilterSecurityInterceptor(),
FilterSecurityInterceptor.class)
;
http.csrf().disable();
customConfigurer(http);
}
}
이렇게 하고 실행한 후에 FilterProxyChain 내부에 디버깅 포인트를 잡으면
우리가 생성한 FilterSecurityInterceptor 가 정상적으로 등록된 것을 확인할 수 있다.
참고: FilterSecurityInterceptor 가 2개 있는데, 권한 체크가 두 번 일어나는 거 아님?
대답은 No 이다. 아래 코드를 보면 이해가 될 것이다.한 번
FilterSecurityInterceptor
를 거치게 되면 다음FilterSecurityInterceptor
는 요청을 바로 다음 Filter Chain 으로 위임한다.
한번 localhost:8080/mypage
로 바로 접근해보자.
MyUrlFilterInvocationSecurityMetadatsSource
에는 현재 Map<자원, 권한목록>
정보가 없어서 권한체크 프로세스가 일어나지 않는다.
이제 Map<자원, 권한목록>
를 DB 에서 정보를 얻어와서 생성하고,
실제 권한 체크가 일어나도록 해보자.
public class SecurityResourceService {
private final ResourcesRepository resourcesRepository;
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();
resourcesList.forEach(re -> {
List<ConfigAttribute> configAttributeList = new ArrayList<>();
re.getRoleSet().forEach(role -> {
configAttributeList.add(new SecurityConfig(role.getRoleName()));
result.put(new AntPathRequestMatcher(re.getResourceName()), configAttributeList);
});
});
return result;
}
}
public class UrlResourcesMapFactoryBean implements FactoryBean<LinkedHashMap<RequestMatcher, List<ConfigAttribute>>> {
private SecurityResourceService securityResourceService;
private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> resourceMap;
public void setSecurityResourceService(SecurityResourceService securityResourceService) {
this.securityResourceService = securityResourceService;
}
@Override
public LinkedHashMap<RequestMatcher, List<ConfigAttribute>> getObject() throws Exception {
if (resourceMap == null) {
init();
}
return resourceMap;
}
private void init() {
resourceMap = securityResourceService.getResourceList();
}
@Override
public Class<?> getObjectType() {
return LinkedHashMap.class;
}
@Override
public boolean isSingleton() {
return FactoryBean.super.isSingleton();
}
}
public class MyUrlFilterInvocationSecurityMetadatsSource implements FilterInvocationSecurityMetadataSource {
private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> requestMap
= new LinkedHashMap<>();
// DI 받는다.
public MyUrlFilterInvocationSecurityMetadatsSource(LinkedHashMap<RequestMatcher, List<ConfigAttribute>> resourcesMap) {
this.requestMap = resourcesMap;
}
// ... 생략 ...
}
@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 생략...
@Autowired
private SecurityResourceService securityResourceService;
private UrlResourcesMapFactoryBean urlResourcesMapFactoryBean() {
UrlResourcesMapFactoryBean urlResourcesMapFactoryBean = new UrlResourcesMapFactoryBean();
urlResourcesMapFactoryBean.setSecurityResourceService(securityResourceService);
return urlResourcesMapFactoryBean;
}
@Bean
public FilterInvocationSecurityMetadataSource myUrlFilterInvocationSecurityMetadataSource() throws Exception {
return new MyUrlFilterInvocationSecurityMetadatsSource(urlResourcesMapFactoryBean().getObject());
}
}
public class MyUrlFilterInvocationSecurityMetadatsSource implements FilterInvocationSecurityMetadataSource {
private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> requestMap;
private SecurityResourceService securityResourceService;
public MyUrlFilterInvocationSecurityMetadatsSource(LinkedHashMap<RequestMatcher, List<ConfigAttribute>> resourcesMap, SecurityResourceService securityResourceService) {
this.requestMap = resourcesMap;
this.securityResourceService = securityResourceService;
}
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());
}
}
}
@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private SecurityResourceService securityResourceService;
@Bean
public MyUrlFilterInvocationSecurityMetadatsSource myUrlFilterInvocationSecurityMetadataSource() throws Exception {
return new MyUrlFilterInvocationSecurityMetadatsSource(urlResourcesMapFactoryBean().getObject(), securityResourceService);
}
// ... 생략 ...
}
@Controller
public class ResourcesController {
//.. 생략 ...
@Autowired
private MyUrlFilterInvocationSecurityMetadatsSource myUrlFilterInvocationSecurityMetadatsSource;
@PostMapping(value="/admin/resources")
public String createResources(ResourcesDto resourcesDto) throws Exception {
ModelMapper modelMapper = new ModelMapper();
Role role = roleRepository.findByRoleName(resourcesDto.getRoleName());
Set<Role> roles = new HashSet<>();
roles.add(role);
Resources resources = modelMapper.map(resourcesDto, Resources.class);
resources.setRoleSet(roles);
resourcesService.createResources(resources);
myUrlFilterInvocationSecurityMetadatsSource.reload();
return "redirect:/admin/resources";
}
@GetMapping(value="/admin/resources/delete/{id}")
public String removeResources(@PathVariable String id, Model model) throws Exception {
Resources resources = resourcesService.getResources(Long.valueOf(id));
resourcesService.deleteResources(Long.valueOf(id));
myUrlFilterInvocationSecurityMetadatsSource.reload();
return "redirect:/admin/resources";
}
}