스프링 시큐리티는 인증(authentication) 인가(authorization)를 위한 프레임워크로, 웹 보안을 담당합니다.
인가란 이미 인증된 사용자에게 특정 리소스에 대한 접근 권한을 부여하거나 제한하는 과정 입니다.
1. 수동적 인가 방식
아래 소스 코드에서 처럼 특정 리소스에 대한 권한을 수동으로 매핑합니다.
- URL 방식
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/shop/login", "/shop/users/**").permitAll()
.antMatchers("/shop/mypage").hasRole("USER")
// 구체적인 경로가 먼저오고 큰 범위의 경로가 뒤에 오도록 작성한다.
.antMatchers("/shop/admin/pay").access("hasRole('ADMIN')")
.antMatchers("/shop/admin/**").access("hasRole('ADMIN') or hasRole('SYS')")
.anyRequest().authenticated()
}
* 주의사항 - 설정 시 구체적인 경로가 먼저오고 그것 보다 큰 범위의 경로가 뒤에 오도록 해야 합니다.
- 메소드 방식
@PerAuthorize 어노테이션은 메소드를 호출하기 전 에 권한을 체크하며, 조건식, 메소드 매개변수, 반환값 등 다양한 정보에 접근하여 복잡한 보안 조건을 정의할 수 있습니다. 또한, 다른 어노테이션과의 결합을 지원합니다.
@Secure는 특정 권한을 가진 사용자만 접근 할수 있도록 제한합니다.
@Configuration
@EnableWebSecurity
//@EnableGlobalMethodSecurity(securedEnabled = true)
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig {
// 설정 작업
}
@Service
public class BookService {
@PreAuthorize("hasRole('ROLE_ADMIN')")
public void addBook(Book book) {
// 로직
}
// @PreAuthorize("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
// @PreAuthorize("hasRole('ROLE_ADMIN') and #param == authentication.principal.username")
@PreAuthorize("#username == authentication.principal.username or hasRole('ROLE_ADMIN')")
public Book getBook(String username, Long bookId) {
// 로직
}
}
// @Secured({"ROLE_USER", "ROLE_ADMIN"})
@Secured("ROLE_ADMIN")
public void someMethod() {
...
}
2. DB 연동 방식
DB와 연동하여 자원 및 권한을 설정하고 제어함으로 동적 권한 관리가 가능하도록 한다.
- 설정 클래스 소스에서 수동적 설정 방식을 모두 제거한다.
- http.antMatchers("/user").access("hasRole('USER')") <- 제거
- 사용자(인증정보)가 /user(요청정보) 에 접근하기 위해서는 ROLE_USER(권한정보) 권한이 필요하다
URL 방식 매핑을 지원하는 FilterInvocationSecurityMetadataSource
아래의 작동 흐름을 보면 사용자가 요청한 리소스를 FilterSecurityInterceptor 에서 받아서 FilterInvocationSecurityMetadataSource 인터페이스를 구현 권한 정보를 조회 하며 최종적으로 접근 결정 관리자 ( AccessDecisionManager) 가 처리하기 된다.
UrlFilterInvocationSecurityMetadataSource 구현
public class UrlFilterInvocationSecurityMetadatsSource implements FilterInvocationSecurityMetadataSource {
private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> requestMap = new LinkedHashMap<>();
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
// 사용자가 어떤 리소스를 요청했나
HttpServletRequest request = ((FilterInvocation) object).getRequest();
// 이 부분을 db 에서 조회하여 매핑해주면 됩니다.
requestMap.put(new AntPathRequestMatcher("/mypage"), Arrays.asList(new SecurityConfig("ROLE_USER")));
if(requestMap != null){
for(Map.Entry<RequestMatcher, List<ConfigAttribute>> entry : requestMap.entrySet()){
RequestMatcher matcher = entry.getKey();
if(matcher.matches(request)){
return entry.getValue(); // 권한 정보 리턴
}
}
}
return null;
}
SecurityConfig 소스 설정
여기서 중요한 부분은 FilterSecurityInterceptor customFilterSecurityInterceptor과 configure 설정 부분에
and().addFilterBefore(customFilterSecurityInterceptor(), FilterSecurityInterceptor.class) 인데요. 우리가 설정한 방식을 적용하게 됩니다. 그럼 UrlFilterInvocationSecurityMetadataSource 를 호출하여 실제 리소스 권한 정보를 매핑하여 전달하게 됩니다.이렇게 되면 수동으로 매핑한 antMatchers 부분은 작동을 하지 않게 됩니다.
@Configuration
@EnableWebSecurity
@Slf4j
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final FormWebAuthenticationDetailsSource formWebAuthenticationDetailsSource;
private final AuthenticationSuccessHandler formAuthenticationSuccessHandler;
private final AuthenticationFailureHandler formAuthenticationFailureHandler;
private final CustomUserDetailService userDetailService;
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authenticationProvider());
auth.authenticationProvider(ajaxAuthenticationProvider());
}
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@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()
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/login_proc")
.authenticationDetailsSource(formWebAuthenticationDetailsSource)
.successHandler(formAuthenticationSuccessHandler)
.failureHandler(formAuthenticationFailureHandler)
.permitAll()
.and()
.exceptionHandling()
// .authenticationEntryPoint(new AjaxLoginAuthenticationEntryPoint())
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))
.accessDeniedPage("/denied")
.accessDeniedHandler(accessDeniedHandler())
.and()
.addFilterBefore(customFilterSecurityInterceptor(), FilterSecurityInterceptor.class)
;
http.csrf().disable();
customConfigurer(http);
}
private void customConfigurer(HttpSecurity http) throws Exception {
http
.apply(new AjaxLoginConfigurer<>())
.successHandlerAjax(ajaxAuthenticationSuccessHandler())
.failureHandlerAjax(ajaxAuthenticationFailureHandler())
.loginProcessingUrl("/api/login")
.setAuthenticationManager(authenticationManagerBean());
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public AuthenticationProvider authenticationProvider(){
return new FormAuthenticationProvider(userDetailService, passwordEncoder());
}
@Bean
public AuthenticationProvider ajaxAuthenticationProvider(){
return new AjaxAuthenticationProvider(passwordEncoder());
}
@Bean
public AjaxAuthenticationSuccessHandler ajaxAuthenticationSuccessHandler(){
return new AjaxAuthenticationSuccessHandler();
}
@Bean
public AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler(){
return new AjaxAuthenticationFailureHandler();
}
public AccessDeniedHandler accessDeniedHandler() {
FormAccessDeniedHandler commonAccessDeniedHandler = new FormAccessDeniedHandler();
commonAccessDeniedHandler.setErrorPage("/denied");
return commonAccessDeniedHandler;
}
// 인가 처리자
@Bean
public FilterSecurityInterceptor customFilterSecurityInterceptor() throws Exception {
FilterSecurityInterceptor filterSecurityInterceptor = new FilterSecurityInterceptor();
filterSecurityInterceptor.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource());
filterSecurityInterceptor.setAccessDecisionManager(affirmativeBased());
filterSecurityInterceptor.setAuthenticationManager(authenticationManagerBean()); // 인증관리자
return filterSecurityInterceptor;
}
// URL 방식 인가 조회
@Bean
public FilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource() {
return new UrlFilterInvocationSecurityMetadatsSource();
}
// 인가 결정자
private AccessDecisionManager affirmativeBased() {
return new AffirmativeBased(getAccessDecistionVoters());
}
private List<AccessDecisionVoter<?>> getAccessDecistionVoters() {
return Arrays.asList(new RoleVoter());
}
}
위 부분 까지 했다면 한단계 더 나아가 실제 DBD에서 조회 하는 방법을 알아봅시다.
db 데이터는 resources key 와 다수의 role 로 key, value 형식으로 저장합니다.
아래 소스는 초기에 권한을 세팅하여 우리가 만든 UrlFilterInvocationSecurityMetadatsSource 에 최종 전달하게 됩니다.
먼저 UrlResourcesMapFactoryBean -> SecurityResourcesService ( 권한 정보 조회 서비스 ) 를 아래와 같이 개발합니다.
@RequiredArgsConstructor
public class UrlResourcesMapFactoryBean implements FactoryBean<LinkedHashMap<RequestMatcher, List<ConfigAttribute>>> {
private final SecurityResourcesService securityResourcesService;
private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> resourceMap;
@Override
public LinkedHashMap<RequestMatcher, List<ConfigAttribute>> getObject() throws Exception {
if ( resourceMap == null ) {
init();
}
return resourceMap;
}
@Override
public Class<?> getObjectType() {
return LinkedHashMap.class;
}
@Override
public boolean isSingleton() {
return true;
}
private void init() {
resourceMap = securityResourcesService.getResourceList();
}
}
@Service
@RequiredArgsConstructor
public class SecurityResourcesService {
private final 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;
}
}
이렇게 해서 권한 정보를 가져오게 되는데요. 설정 시 구체적인 경로가 먼저오고 그것 보다 큰 범위의 경로가 뒤에 오도록 해야 하므로 권한 정보를 담을때는 LinkHashMap으로 순서를 보장하도록 합니다.
마지막으로 SecurityConfig에서 설정을 하면 됩니다
실시간으로 권한 반영
public void reload() {
LinkedHashMap<RequestMatcher, List<ConfigAttribute>> reloadMap = securityResourceService.getResourceList();
Iterator<Map.Entry<RequestMatcher, List<ConfigAttribute>>> iterator = reloadMap.entrySet().iterator();
requestMap.clear();
while( iterator.hasNext()) {
Map.Entry<RequestMatcher, List<ConfigAttribute>> next = iterator.next();
requestMap.put(next.getKey(), next.getValue());
}
}
UrlFilterInvocationSecurityMetadatsSource 에서 실제 권한 정보가 변경 되었을 경우 위 코드를 호출하여 권한 정보를 다시 리로드 하게 됩니다.
이때 권한 변경 로직이 있는 소스에서 UrlFilterInvocationSecurityMetadatsSource를 주입받아 urlFilterInvocationSecurityMetadatsSource.reload() 를 호출하면 권한이 실시간으로 반영 되게 됩니다.
PermitAllFlter
인증 및 권한심사를 할 필요가 없는 자원 ex ( "/", /home", "/index", "/login"..) 등을 설정해서 바로 리소스 접근이 가능하게 하는 필터
내부 동작원리를 보면 실제 리소스가 권한이 없다면 List<ConfigAttribute> 가 null 이면 권한 심사없이 바로 통과 되게끔 시큐리티는 작동을 하게됩니다.
SecurityConfig에서 permitAll() 로 처리해도 되지만 불필요한 인가 처리 과정을 없애기 위해 응용동작을 구현 해보겠습니다.
응용 동작은 인가처리 심사 전에 FilterSecurityInterceptor을 상속받는 PermitAllFilter 클래스를 만들어 미리 심사가 필요없는 자원에 대해서 null 로 리턴하게 하는 동작 원리 입니다.
public class PermitAllFilter extends FilterSecurityInterceptor {
private static final String FILTER_APPLIED = "__spring_security_filterSecurityInterceptor_filterApplied";
private boolean observeOncePerRequest = true;
// 심사하지 않는 리소스 항목
private List<RequestMatcher> permitAllRequestMatchers = new ArrayList<>();
public PermitAllFilter(String...permitAllResources){
for(String resource : permitAllResources){
permitAllRequestMatchers.add(new AntPathRequestMatcher(resource));
}
}
@Override
protected InterceptorStatusToken beforeInvocation(Object object) {
// 처리 과정
boolean permitAll = false;
HttpServletRequest request = ((FilterInvocation) object).getRequest();
for(RequestMatcher requestMatcher : permitAllRequestMatchers){
if(requestMatcher.matches(request)){
permitAll = true;
break;
}
}
// null로 리턴
if(permitAll){
return null;
}
return super.beforeInvocation(object);
}
public void invoke(FilterInvocation fi) throws IOException, ServletException {
if ((fi.getRequest() != null)
&& (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
&& observeOncePerRequest) {
// filter already applied to this request and user wants us to observe
// once-per-request handling, so don't re-do security checking
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
else {
// first time this request being called, so perform security checking
if (fi.getRequest() != null && observeOncePerRequest) {
fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}
InterceptorStatusToken token = beforeInvocation(fi);
try {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
finally {
super.finallyInvocation(token);
}
super.afterInvocation(token, null);
}
}
}
--- SecurityConfig.java
private String[] permitAllResources = {"/", "/login", "/user/login/**"};
// FilterSecurityInterceptor -> PermitAllFilter로 적용
@Bean
public PermitAllFilter customFilterSecurityInterceptor() throws Exception {
PermitAllFilter permitAllFilter = new PermitAllFilter(permitAllResources);
permitAllFilter.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource());
permitAllFilter.setAccessDecisionManager(affirmativeBased());
permitAllFilter.setAuthenticationManager(authenticationManagerBean());
return permitAllFilter;
}
계층 권한 적용하기
계층권한 적용
RoleHierarchy 상위 계층 Role는 하위 계층 Role의 자원에 접근을 가능하게 합니다.
ROLE_ADMIN > ROLE_MANAGER > ROLE_USER 일 경우 ROLE_ADMIN 권한 만 있으면 하위 ROLE의 권한을 모두 포함 합니다.
RoleHierarchyVoter는 RoleHierarchy를 생성자로 받으며 이 클래스에서 설정한 규칙이 적용되어 심하합니다.
먼저, 위 이미지 처럼 권한 계층 구조를 DB에서 조회하여 똑같이 세팅하여야 하며, 그전에
DB ROLE_HIERARCHY 테이블에서 부모 자식의 구조를 만들어야 합니다.
ROLE_ADMIN > ROLE_MANAGER
ROLE_MANAGER > ROLE_USER
@Service
public class RoleHierarchyServiceImpl implements RoleHierarchyService {
@Autowired
private RoleHierarchyRepository roleHierarchyRepository;
@Transactional
@Override
public String findAllHierarchy() {
List<RoleHierarchy> rolesHierarchy = roleHierarchyRepository.findAll();
Iterator<RoleHierarchy> itr = rolesHierarchy.iterator();
StringBuilder concatedRoles = new StringBuilder();
while (itr.hasNext()) {
RoleHierarchy roleHierarchy = itr.next();
if (roleHierarchy.getParentName() != null) {
concatedRoles.append(roleHierarchy.getParentName().getChildName());
concatedRoles.append(" > ");
concatedRoles.append(roleHierarchy.getChildName());
concatedRoles.append("\n");
}
}
return concatedRoles.toString();
}
}
-- 서버가 초기화 될때 해당 하이라키 구조를 가져 옵니다.
-- 그리고 RoleHierachy에 조회한 구조를 set 합니다.
@Component
public class SecurityInitializer implements ApplicationRunner {
@Autowired
private RoleHierarchyService roleHierarchyService;
@Autowired
private RoleHierarchyImpl roleHierarchy;
@Override
public void run(ApplicationArguments args) throws Exception {
String allHierarchy = roleHierarchyService.findAllHierarchy();
roleHierarchy.setHierarchy(allHierarchy);
}
}
-- SecurityConfig 설정
private List<AccessDecisionVoter<?>> getAccessDecisionVoters() {
List<AccessDecisionVoter<? extends Object>> accessDecisionVoters = new ArrayList<>();
accessDecisionVoters.add(roleVoter());
return accessDecisionVoters;
}
@Bean
public AccessDecisionVoter<? extends Object> roleVoter() {
RoleHierarchyVoter roleHierarchyVoter = new RoleHierarchyVoter(roleHierarchy());
return roleHierarchyVoter;
}
@Bean
public RoleHierarchyImpl roleHierarchy() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
return roleHierarchy;
}
아이피 접속 제한하기
- 특정한 IP 만 접근이 가능하도록 심의하는 Voter 추가
- Voter 중에서 가장 먼저 심사하도록 하여 허용된 IP 일 경우에만 최종 승인 및 거부 결정을 하도록 한다.
- 허용된 IP 이면 ACCESS_GRANTED 가 아닌 ACCESS_ABSTAIN을 리턴하여 추가 심의를 계속 진행 하도록 한다.
- 허용된 IP 가 아니면 ACCESS_DENIED를 리턴하지 않고 즉지 예외를 발생하여 최종 자원 접근을 거부한다.
-- SecurityConfig 설정
private List<AccessDecisionVoter<?>> getAccessDecisionVoters() {
List<AccessDecisionVoter<? extends Object>> accessDecisionVoters = new ArrayList<>();
accessDecisionVoters.add(new IpAddressVoter(securityResourceService));
accessDecisionVoters.add(roleVoter());
return accessDecisionVoters;
}
-- IP Address Vote
public class IpAddressVoter implements AccessDecisionVoter<Object> {
private SecurityResourceService securityResourceService;
public IpAddressVoter(SecurityResourceService securityResourceService) {
this.securityResourceService = securityResourceService;
}
@Override
public boolean supports(ConfigAttribute attribute) {
return true;
}
@Override
public boolean supports(Class<?> clazz) {
return true;
}
@Override
public int vote(Authentication authentication, Object object, Collection<ConfigAttribute> attributes) {
WebAuthenticationDetails details = (WebAuthenticationDetails)authentication.getDetails();
String remoteAddress = details.getRemoteAddress();
List<String> accessIpList = securityResourceService.getAccessIpList();
int result = ACCESS_DENIED;
for(String ipAddress : accessIpList){
if(remoteAddress.equals(ipAddress)){
return ACCESS_ABSTAIN;
}
}
if(result == ACCESS_DENIED){
throw new AccessDeniedException("Invalid IpAddress");
}
return result;
}
-- SecurityResourceService 에 추가
@Service
@RequiredArgsConstructor
public class SecurityResourceService {
private final ResourcesRepository resourcesRepository;
private final AccessIpRepository accessIpRepository;
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 List<String> getAccessIpList() {
List<String> accessIpList = accessIpRepository.findAll().stream().map(accessIp -> accessIp.getIpAddress()).collect(Collectors.toList());
return accessIpList;
}
}
'SpringSecurity' 카테고리의 다른 글
JWT (1) | 2023.12.12 |
---|---|
인증 FormLogin (0) | 2023.12.12 |