[Spring Security5] SecurityFilterChain, Filter 이중 호출 해결 (Filter disabled)
Multiple HttpSecurity 구성에서 서비스를 이동할 때, 각 서비스에서 사용하는 권한으로 변경하는 필터 AuthorityChangeFilter를 등록하려고 합니다.
우선 SecurityFilterChain 구성을 보면,
SecurityFilterChain은 Filter 보다 먼저 실행됩니다.
AuthorityChangeFilter(권한을 변경하여 적용하기 위한 Filter)를 SecurityFilterChain이 아닌 Filter로 등록하면,
서비스를 이동할 때(user -> admin) SecurityFilterChain-FilterSecurityInterceptor에서 보유 중인 권한(user 권한)으로 접근 권한을 확인하여 해당 페이지의 접근 권한(admin 권한)이 없으면 AuthorityChangeFilter를 호출하지 않고 403 오류를 냅니다.
Secure object: FilterInvocation: URL: /admin/main; Attributes: [ROLE_ADMIN]
Previously Authenticated: org.springframework.security.authentication.UsernamePasswordAuthenticationToken@...
... Principal: SecurityMemberInfoVO(empId=user3, ... authorities=[ROLE_USER, ROLE_SYSTEM_USER] ...)]);
... Credentials: [PROTECTED]; Authenticated: true; ... Granted Authorities: ROLE_USER, ROLE_SYSTEM_USER]
Voter: org.springframework.security.access.vote.RoleVoter@5de634bd, returned: -1
Access is denied (user is not anonymous); delegating to AccessDeniedHandler
org.springframework.security.access.AccessDeniedException: Access is denied
이 기능이 제대로 작동하기 위해서는 Security FilterChain 안에서 Authentication 로딩 후 (After SecurityContextPersistenceFilter) AuthorityChangeFilter가 실행되어야 합니다. (이유는 아래에서 설명)
.addFilterAfter(authorityChangeFilter, SecurityContextPersistenceFilter.class)
@Order(1)
@Configuration
@RequiredArgsConstructor
public class UserSecurityConfiguration extends WebSecurityConfigurerAdapter {
private final AuthorityChangeFilterauthorityChangeFilter;
// ...
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.antMatcher("/user/**")
.csrf().disable().anonymous() // CSRF OFF
.and()
.authorizeRequests().anyRequest().authenticated() // 사용자 인증이 된 요청에 대해서만 요청 허용
.addFilterAfter(authorityChangeFilter, SecurityContextPersistenceFilter.class) // 권한 변경 Filter
.addFilterAfter(filterSecurityInterceptor, FilterSecurityInterceptor.class) // 접근권한 체크
.and()
.formLogin()
.loginPage("/user/login")
// ...
// https://blog.naver.com/myh814/221925149440
@Order(2)
@Configuration
@RequiredArgsConstructor
public class AdminSecurityConfiguration extends WebSecurityConfigurerAdapter {
private final AuthorityChangeFilter authorityChangeFilter;
// ...
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.antMatcher("/admin/**")
.csrf().disable().anonymous() // CSRF OFF
.and()
.authorizeRequests().anyRequest().authenticated() // 사용자 인증이 된 요청에 대해서만 요청 허용
.addFilterAfter(authorityChangeFilter, SecurityContextPersistenceFilter.class) // 권한 변경 Filter
.addFilterAfter(filterSecurityInterceptor, FilterSecurityInterceptor.class) // 접근권한 체크
.and()
.formLogin()
.loginPage("/admin/login")
// ...
// https://blog.naver.com/myh814/221925149440
Spring Boot를 사용하는 경우 class에 @Component 선언을 하거나 @Bean 등록을 하면 filter가 자동으로 추가되기 때문에 SecurityFilter를 설정하면 SecurityFilter와 Filter, 이중 호출하게 됩니다.
Spring docs : When using an embedded servlet container, you can register servlets, filters, and all the listeners (such as HttpSessionListener) from the Servlet spec, either by using Spring beans or by scanning for Servlet components.
이를 방지하기 위해 FilterRegistrationBean으로 AuthorityChangeFilter를 disabled 합니다.
Spring docs : In the case of Filters, you can also add mappings and init parameters by adding a FilterRegistrationBean instead of or in addition to the underlying component.
@Bean
public FilterRegistrationBean authorityChangeFilter() {
FilterRegistrationBean filter = new FilterRegistrationBean();
// List<String> urlPattern = new ArrayList<>();
// urlPattern.add("/user/*"); // */
// urlPattern.add("/admin/*"); // */
// filter.setUrlPatterns(urlPattern);
//
// filter.setOrder(Ordered.LOWEST_PRECEDENCE - 2);
filter.setFilter(getAuthorityChangeFilter());
filter.setEnabled(false); // disabled
return filter;
}
@Bean
public AuthorityChangeFilter getAuthorityChangeFilter() {
return new AuthorityChangeFilter();
}
AuthorityChangeFilter에 비로그인으로 SecurityContextPersistenceFilter가 Authentication을 생성하지 않은 경우를 방지하는 코드를 추가합니다.
※ 주의 : 만약 AuthorityChangeFilter가 SecurityContextPersistenceFilter 다음이 아닌 AnonymousAuthenticationFilter 이후에 실행된다면, 비로그인 시 인증토큰에 anonymousUser 권한이 들어가 아래 방지코드를 지나쳐 오류를 발생시킬 수 있습니다.
if (SecurityContextHolder.getContext().getAuthentication() == null) {
chain.doFilter(request, response);
return;
}
(Filter를 implements 해도 되지만, Filter의 spring config 설정 정보를 쉽게 처리하기 위한 GenericFilterBean을 상속받았습니다.)
public class AuthorityChangeFilter extends GenericFilterBean {
@Autowired
private AuthorityService authorityService;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException, RuntimeException {
if (SecurityContextHolder.getContext().getAuthentication() == null) {
// 미로그인. 아무런 처리 없이 다음 필터로 넘김.
chain.doFilter(request, response);
return;
}
String requestUri = ((HttpServletRequest)request).getRequestURI();
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
Object principal = auth.getPrincipal();
SecurityMemberInfoVO memberInfo = (SecurityMemberInfoVO)principal;
memberInfo.setAuthorities(authorityService.getEmpAuthorityList(EmpAuthorityVO.builder().empId(memberInfo.getEmpId()).svcCd(this.getSvcCd(requestUri)).build()));
// 권한 재설정
Authentication newAuth = new UsernamePasswordAuthenticationToken(memberInfo, memberInfo.getPassWd(), memberInfo.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(newAuth);
chain.doFilter(request, response);
}
}
이로써, 서비스를 이동할 때(user -> admin) SecurityFilterChain-AuthorityChangeFilter에서 서비스에 맞는 권한(admin 권한)으로 변경하고 SecurityFilterChain-FilterSecurityInterceptor에서 보유 중인 권한(admin 권한)으로 접근 권한을 확인하여 해당 페이지의 접근 권한(admin 권한)이 있으면 화면을 노출합니다.
Secure object: FilterInvocation: URL: /admin/main; Attributes: [ROLE_ADMIN]Previously Authenticated: org.springframework.security.authentication.UsernamePasswordAuthenticationToken@...
... Principal: SecurityMemberInfoVO(empId=user3, ... authorities=[ROLE_ADMIN] ...)]);
... Credentials: [PROTECTED]; Authenticated: true; Details: null; Granted Authorities: ROLE_ADMIN
Voter: org.springframework.security.access.vote.RoleVoter@d269d, returned: -1
Authorization successful
(+)
Spring Security Filter에 대해 이야기한 김에 몇가지 더 설명하겠습니다.
1.
위에서 얘기했듯이 SecurityContextHolder를 이용하면 현재 요청이 로그인한 유저의 정보인지 인증정보를 조회할 수 있습니다.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth instanceof AnonymousAuthenticationToken) {
//
}
auth의 값이 null이면 SecurityFilterChain을 타지않는 페이지이고,
- 예) web.ignoring().antMatchers(SECURITY_EXCLUDE_PATTERN_ARR);
auth의 값이 anonymousUser이면 SecurityFilterChain을 탔지만 로그인 인증을 하지않은 유저입니다.
2.
Request 속성 값 중 "__spring_security_filterSecurityInterceptor_filterApplied"를 조회하면 현재 요청한 페이지가 SecurityFilterChain을 타는 페이지인지 아닌지 알 수 있습니다.
public class SecurityCheckFilter implements Filter {
private static final String FILTER_SECURITY_INTERCEPTOR_APPLIED = "__spring_security_filterSecurityInterceptor_filterApplied";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException, RuntimeException {
Boolean isAppliedSecurityFilterInterceptor = (Boolean)request.getAttribute(FILTER_SECURITY_INTERCEPTOR_APPLIED); //security 보호자원이 적용된 request
if (isAppliedSecurityFilterInterceptor == null || !isAppliedSecurityFilterInterceptor) {
chain.doFilter(request, response);
return;
}
// ...
}
@Override
public void destroy() {
}
}
isAppliedSecurityFilterInterceptor의 값이 null이면 SecurityFilterChain을 타지않는 페이지이고,
isAppliedSecurityFilterInterceptor의 값이 false이면 security설정시 등록한 리소스 자원이 아닌 페이지입니다.
(Spring security6에서는 SecurityContextHolderFilter.class.getName() + ".APPLIED"; 로 사용하면됩니다.)
[Reference]