1. 서론
- 이번 포스팅에서는 URL이 조회될 때마다 해당 URL의 접근권한을 확인해서 접속 가능, 불가능을 처리하는 방법을 포스팅해보겠습니다.
- 과정이 조금 많이 복잡해서 이해하기 힘들었는데, 수정하거나 고칠 점이 있다면 알려주시면 감사하겠습니다.
2. 본론
- 우선 ROLE - ROLE_RESOURCES - RESOURCES, 이렇게 세 가지의 테이블이 존재합니다.
- 이는 ROLE - RESOURCES가 원래 M:N 매핑되어있어야 하지만, 1:N + N:1 관계로 쪼개 놓기 위함입니다.
- DB 데이터는 미리 설정하여 입력해놓았습니다.
- H2 DB는 간단한 CRUD를 DB내부에서 간단하게 처리할 수 있도록 돕습니다.
Role.java
- 도메인 설계를 시작하겠습니다. 다음 3가지 테이블의 도메인을 설계해줍니다.
import lombok.*;
import javax.persistence.*;
import java.io.Serializable;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;
@Entity
@Getter
@ToString(exclude = {"roleResources"})
@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;
@OneToMany(mappedBy = "resources",cascade = CascadeType.ALL)
private Set<RoleResources> roleResources = new LinkedHashSet<>();
}
RoleResources.java
import lombok.*;
import javax.persistence.*;
import java.io.Serializable;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 생성자를 다른 계층에서 함부로 생성하지 못하도록 막는다.
public class RoleResources implements Serializable {
@Id
@GeneratedValue
@Column(name = "role_resource_id")
private Long id;
// 1:N에서 N쪽을 담당하고 있읍니다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "resource_id")
private Resources resources;
// 1:N에서 N쪽을 담당하고 있읍니다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "role_id")
private Role role;
}
Resources.java
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
@Getter
@ToString(exclude = {"roleResources"}) // toString시 roleResources은 빼고 나타내줌
@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;
@OneToMany(mappedBy = "role", cascade = CascadeType.ALL)
private Set<RoleResources> roleResources = new HashSet<>();
}
다음은 코드의 핵심 부분인 SecurityConfig부분입니다. 이 코드는 그동안 제가 포스팅해오면서 누적된 코드가 포함되어있습니다.
SecurityConfig.java
import com.ktnet.security.factory.UrlResourcesMapFactoryBean;
import com.ktnet.security.handler.*;
import com.ktnet.security.metadatasource.UrlFilterInvocationSecurityMetadataSource;
import com.ktnet.service.SecurityResourceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDecisionManager;
import org.springframework.security.access.AccessDecisionVoter;
import org.springframework.security.access.vote.AffirmativeBased;
import org.springframework.security.access.vote.RoleVoter;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
import java.util.Arrays;
import java.util.List;
@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private SecurityResourceService securityResourceService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
String password = passwordEncoder().encode("123");
auth
.inMemoryAuthentication()
.withUser("user").password(password).roles("USER")
.and()
.withUser("manager").password(password).roles("USER", "MANAGER")
.and()
.withUser("admin").password(password).roles("USER", "MANAGER", "ADMIN");
auth.userDetailsService(userDetailsService);
}
@Override
// js, css, image 설정은 보안 설정의 영향 밖에 있도록 만들어주는 설정.
public void configure(WebSecurity web) throws Exception {
web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
// .antMatchers("/loginUser","/login*","/users/new","/").permitAll()
// .antMatchers("/users/user").hasRole("USER")
// .antMatchers("/users/manager").hasRole("MANAGER")
// .antMatchers("/users/admin").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/users/login") // template 커스터마이징
.defaultSuccessUrl("/") // 성공시 어디로갈까?
.failureUrl("/users/login") // 실패시 어디로갈까?
.successHandler(new CustomSuccessHandler())
.failureHandler(new CustomFailureHandler())
.permitAll() // 로그인 경로는 모두 허용해줘야 한다.
.and()
.exceptionHandling()
.accessDeniedHandler(accessDeniedHandler()) // 인증 거부 관련 처리
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/")
.addLogoutHandler(new CustomLogoutHandler())
.logoutSuccessHandler(new CustomLogoutSuccessHandler())
.deleteCookies("remember-me")
.and()
.addFilterBefore(customFilterSecurityInterceptor(),FilterSecurityInterceptor.class);
}
private AccessDeniedHandler accessDeniedHandler() {
CustomAccessDeniedHandler accessDeniedHandler = new CustomAccessDeniedHandler();
accessDeniedHandler.setErrorPage("/users/denied");
return accessDeniedHandler;
}
@Bean
// BCryptPasswordEncoder는 Spring Security에서 제공하는 비밀번호 암호화 객체입니다.
// Service에서 비밀번호를 암호화할 수 있도록 Bean으로 등록합니다.
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public FilterSecurityInterceptor customFilterSecurityInterceptor() throws Exception {
FilterSecurityInterceptor filterSecurityInterceptor = new FilterSecurityInterceptor();
filterSecurityInterceptor.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource()); // 권한정보 셋팅
filterSecurityInterceptor.setAccessDecisionManager(affirmativeBased());
filterSecurityInterceptor.setAuthenticationManager(authenticationManagerBean()); // 인증매니저
return filterSecurityInterceptor;
}
private AccessDecisionManager affirmativeBased() {
AffirmativeBased affirmativeBased = new AffirmativeBased(getAccessDecisionVoters());
return affirmativeBased;
}
private List<AccessDecisionVoter<? extends Object>> getAccessDecisionVoters() {
return Arrays.asList(new RoleVoter());
}
@Bean
public FilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource() throws Exception {
return new UrlFilterInvocationSecurityMetadataSource(urlResourcesMapFactoryBean().getObject());
}
private UrlResourcesMapFactoryBean urlResourcesMapFactoryBean() {
UrlResourcesMapFactoryBean urlResourcesMapFactoryBean = new UrlResourcesMapFactoryBean();
urlResourcesMapFactoryBean.setSecurityResourceService(securityResourceService);
return urlResourcesMapFactoryBean;
}
// 인증 매니저
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
- 이전 포스팅까지는 아래의 정적 코드를 사용하여 정적으로 박아놓았지만, 이제는 DB를 조율해서 동적으로 자유롭게 추가할 수 있는 가능성(?)이 주어졌습니다.
// .antMatchers("/loginUser","/login*","/users/new","/").permitAll()
// .antMatchers("/users/user").hasRole("USER")
// .antMatchers("/users/manager").hasRole("MANAGER")
// .antMatchers("/users/admin").hasRole("ADMIN")
- 주의 깊게 보아야 할 부분은 이 부분입니다. FilterSecurityInterceptor클래스를 이용하여 매 순간 인증 권한이 있는지를 검사해줍니다.
.and().addFilterBefore(customFilterSecurityInterceptor(),FilterSecurityInterceptor.class);
커스텀 된 customFilterSecurityInterceptor() 메서드는 @Bean 등록 이후 아래의 코드와 같이 꾸며줍니다.
@Bean
public FilterSecurityInterceptor customFilterSecurityInterceptor() throws Exception {
FilterSecurityInterceptor filterSecurityInterceptor = new FilterSecurityInterceptor();
filterSecurityInterceptor.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource()); // 권한정보 셋팅
filterSecurityInterceptor.setAccessDecisionManager(affirmativeBased());
filterSecurityInterceptor.setAuthenticationManager(authenticationManagerBean()); // 인증매니저
return filterSecurityInterceptor;
}
- 이중 권한 정보를 세팅하는 과정은 매우 어렵기 때문에, 포스팅 정리하는데도 매우 힘들었습니다.
filterSecurityInterceptor.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource()); // 권한정보 셋팅
1. filterSecurityInterceptor는 set을 합니다. 그 set이 뭐냐면 setSecurityMetadataSource를 해서 새로운 보안 메타데이터 소스를 설정하는데, urlFilterInvocationSecurityMetadataSource()라는 메서드로 하겠다는 뜻입니다.
2. urlFilterInvocationSecurityMetadataSource()는 아래의 @Bean으로 등록된 메서드를 실행시킵니다.
@Bean
public FilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource() throws Exception {
return new UrlFilterInvocationSecurityMetadataSource(urlResourcesMapFactoryBean().getObject());
}
놀랍게도 반환 값이 UrlFilterInvocationSecurityMetadataSource입니다. urlResourceMapFactoryBean()이라는 메서드에서 getObject()라는 놈을 파라미터로 받아 뭘 하려는 것 같습니다.
3. 결론
urlResourceMapFactoryBean()은 뭘 하는 놈인지 2편에서 자세하게 알아보겠습니다.
'Dev > SpringBoot' 카테고리의 다른 글
16. [SpringBoot] 스프링부트 카카오 로그인하기 구현(따라치기만하면됨)(1) (0) | 2020.07.23 |
---|---|
15. [Spring Boot] URL별 접근권한 DB에서 가져와서 처리하기(2) (3) | 2020.06.18 |
13. [springboot] 스프링부트 접근 불가 처리 (0) | 2020.06.13 |
12. [springboot] Spring Boot 로그인 실패 이후 처리 (0) | 2020.06.12 |
11. [springboot] 스프링부트 로그인 성공 이후 처리 (0) | 2020.06.12 |