Overview
스프링 부트 3.0부터 스프링 시큐리티 6 버전이 적용되었습니다. 삭제되거나 deprecated된 코드가 많아서 마이그레이션 시 주의할 부분에 대해 알려드리겠습니다.
- 기존 버전 : 5.3.3.RELEASE
- 최신 버전 : 6.2.4
Summary
- 기존 WebSecurityConfigurationAdapter를 상속받아 세팅하던 방식은 삭제되었고 SecurityFilterChain bean을 스프링 컨테이너에 등록해줘야함.
- authorizeRequests() → authorizeHttpRequests()로 변경
- antMatchers() → requestMatchers()로 변경
- 로그인 페이지 리다이렉트 반복 접근 이슈 발생 시
- .dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll() 추가
- https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html#match-by-dispatcher-type
- @EnableRedisHttpSession → @EnableRedisIndexedHttpSession로 변경
- spring.redis 에서 spring.data.redis로 properties 변경
- https://docs.spring.io/spring-boot/docs/current/reference/html/application-properties.html
- FilterSecurityInterceptor → AuthorizationFilter로 변경
- SecurityContextPersistenceFilter deprecated
- @EnableGlobalMethodSecurity → @EnableMethodSecurity
Detail
V5)
기존 코드는 WebSecurityConfigurerAdapter를 상속받아 시큐리티 설정을 합니다.
@Configuration
@RequiredArgsConstructor
public class UserSecurityConfiguration extends WebSecurityConfigurerAdapter {
private final UserLoginSuccessHandler loginSuccessHandler;
private final UserLoginFailureHandler loginFailureHandler;
private final RoleHierarchyService roleHierarchyService; //권한 처리
@PostConstruct
public void init() {
// spring hierarchy 관련 셋팅
roleHierarchyService.resetRoleHierarchyData();
}
@Override
public void configure(WebSecurity web) {
web.ignoring().antMatchers(SECURITY_EXCLUDE_PATTERN_ARR);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.antMatcher("/user/**")
.addFilterAfter(filterSecurityInterceptor(), FilterSecurityInterceptor.class) // https://blog.naver.com/myh814/221934064615
.authorizeRequests().anyRequest().authenticated() // 사용자 인증이 된 요청에 대해서만 요청 허용
.and()
.csrf().disable().anonymous() // CSRF OFF
.and()
.formLogin()
.loginPage("/user/login")
.defaultSuccessUrl("/user/main")
.loginProcessingUrl("/user/login/loginUser")
.successHandler(loginSuccessHandler)
.failureHandler(loginFailureHandler)
.usernameParameter("empId")
.passwordParameter("passWd")
.and()
.logout()
.logoutUrl("/user/logout")
.logoutSuccessUrl("/user/login")
.and()
.exceptionHandling()
.accessDeniedPage("/error/notAuth")
;
http
.headers()
.frameOptions()
.sameOrigin()
.httpStrictTransportSecurity()
.includeSubDomains(true)
.maxAgeInSeconds(3600)
;
http
.sessionManagement()
.sessionFixation()
.changeSessionId()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.expiredSessionStrategy(sessionInformationExpiredStrategy)
.sessionRegistry(springSessionBackedSessionRegistry)
;
}
/**
* 접근 권한 검사
* - https://docs.spring.io/spring-security/site/docs/4.1.5.RELEASE/reference/html/core-web-filters.html#filter-security-interceptor
* - https://blog.naver.com/myh814/221934064615
*
* @return
* @throws Exception
*/
@Bean
public FilterSecurityInterceptor filterSecurityInterceptor() throws Exception {
FilterSecurityInterceptor interceptor = new FilterSecurityInterceptor();
interceptor.setAccessDecisionManager(accessDecisionManager()); // AccessDecisionManager에 권한검사 위임
interceptor.setSecurityMetadataSource(filterInvocationSecurityMetadataSource());
return interceptor;
}
/**
* 사용자가 수동으로 권한 갱신하기 위해 securedObjectService 처리
*
* @return
*/
@Bean
public ReloadableFilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource() {
List<RequestMatcher> matchers = new ArrayList<>();
for (String pattern : SECURITY_EXCLUDE_PATTERN_ARR) {
matchers.add(new AntPathRequestMatcher(pattern));
}
return new ReloadableFilterInvocationSecurityMetadataSource(matchers);
}
}
import lombok.RequiredArgsConstructor;
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.hierarchicalroles.RoleHierarchy;
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl;
import org.springframework.security.access.vote.AffirmativeBased;
import org.springframework.security.access.vote.RoleVoter;
import java.util.ArrayList;
import java.util.List;
@Configuration
@RequiredArgsConstructor
public class RoleHierarchyConfiguration {
/**
* role hierarchy singleton
*
* @return
*/
@Bean
public RoleHierarchy roleHierarchy() {
return new RoleHierarchyImpl();
}
/**
* 권한 설정용
* - spring security 권한 필터 개념 내용 참고
* - https://docs.spring.io/spring-security/site/docs/3.0.x/reference/technical-overview.html#tech-intro-access-control
* - https://docs.spring.io/spring-security/site/docs/4.1.5.RELEASE/reference/html/ns-config.html#ns-access-manager
*
* @return
*/
@Bean
public AccessDecisionManager accessDecisionManager() {
List<AccessDecisionVoter<?>> decisionVoters = new ArrayList<AccessDecisionVoter<?>>();
RoleVoter roleVoter = new RoleVoter();
roleVoter.setRolePrefix("");
decisionVoters.add(roleVoter);
AffirmativeBased affirm = new AffirmativeBased(decisionVoters);
affirm.setAllowIfAllAbstainDecisions(false);
return affirm;
}
}
public class ReloadableFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
@Autowired
private RedisRequestMapService redisRequestMapService;
private final List<RequestMatcher> matchers;
/**
* 생성자 내 requestMap 초기화
*
* @param matchers
*/
public ReloadableFilterInvocationSecurityMetadataSource(List<RequestMatcher> matchers) {
this.matchers = matchers;
}
/**
* break문 처리하여 매칭되는 권한이 있으면 바로 리턴
* 즉, DB에서 권한을 얻어올 때 순서가 의미가 있다
*
* @param object
* @return
* @throws IllegalArgumentException
*/
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
HttpServletRequest request = ((FilterInvocation)object).getRequest();
Collection<ConfigAttribute> result = null;
// 아래 설정에 대한 참고문서 https://github.com/spring-projects/spring-session/issues/244
// set excluding uri
for (RequestMatcher matcher : matchers) {
if (matcher.matches(request)) {
request.setAttribute("org.springframework.session.web.http.SessionRepositoryFilter.FILTERED", Boolean.TRUE);
return result;
}
}
Map<RequestMatcher, Collection<ConfigAttribute>> requestMap = redisRequestMapService.getRequestMap();
for (Map.Entry<RequestMatcher, Collection<ConfigAttribute>> entry : requestMap.entrySet()) {
if (entry.getKey().matches(request)) {
result = entry.getValue();
break;
}
}
return result;
}
/**
* @return
*/
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
Set<ConfigAttribute> allAttributes = new HashSet<>();
Map<RequestMatcher, Collection<ConfigAttribute>> requestMap = redisRequestMapService.getRequestMap();
for (Map.Entry<RequestMatcher, Collection<ConfigAttribute>> entry : requestMap.entrySet()) {
allAttributes.addAll(entry.getValue());
}
return allAttributes;
}
@Override
public boolean supports(Class<?> clazz) {
return FilterInvocation.class.isAssignableFrom(clazz);
}
}
V6)
새로운 코드는 SecurityFilterChain Bean을 사용하여 시큐리티 설정을 합니다.
import com.biz.config.security.custom.AccessDecisionManagerAuthorizationManagerAdapter;
import com.biz.config.security.handler.LoginFailureHandler;
import com.biz.config.security.handler.LoginSuccessHandler;
import jakarta.servlet.DispatcherType;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@RequiredArgsConstructor
public class UserSecurityConfiguration {
private final UserLoginSuccessHandler loginSuccessHandler;
private final UserLoginFailureHandler loginFailureHandler;
private final AccessDecisionManagerAuthorizationManagerAdapter authorizationManager;
private final RoleHierarchyService roleHierarchyService; //권한 처리
@PostConstruct
public void init() {
// spring hierarchy 관련 셋팅
roleHierarchyService.resetRoleHierarchyData();
}
@Bean
public WebSecurityCustomizer configure() {
return web -> web.ignoring().requestMatchers(SECURITY_EXCLUDE_PATTERN_ARR);
}
@Bean
public SecurityFilterChain userFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/user/**") // */
.authorizeHttpRequests(authorize -> authorize
.dispatcherTypeMatchers(DispatcherType.FORWARD, DispatcherType.ERROR).permitAll()
.anyRequest().access(authorizationManager)
// .anyRequest().authenticated()
)
.csrf(AbstractHttpConfigurer::disable) // CSRF OFF
.formLogin(formLogin -> formLogin
.loginPage("/user/login").permitAll()
.defaultSuccessUrl("/user/main")
.loginProcessingUrl("/user/login/loginUser")
.successHandler(loginSuccessHandler)
.failureHandler(loginFailureHandler)
.usernameParameter("empId")
.passwordParameter("passWd")
)
.logout(logout -> logout
.logoutUrl("/user/logout")
.logoutSuccessUrl("/user/login")
)
.exceptionHandling(e -> e.accessDeniedPage("/error/notAuth"))
;
http.headers(header -> header
.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
.httpStrictTransportSecurity(hstsConfig -> hstsConfig
.includeSubDomains(true)
.maxAgeInSeconds(3600)
)
);
http.sessionManagement(session -> session
.sessionFixation()
.changeSessionId()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.expiredSessionStrategy(sessionInformationExpiredStrategy)
.sessionRegistry(springSessionBackedSessionRegistry)
);
return http.build();
}
}
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl;
@Configuration
public class RoleHierarchyConfiguration{
/**
* role hierarchy singleton
*
* @return
*/
@Bean
public RoleHierarchy roleHierarchy(){
return new RoleHierarchyImpl();
}
}
import com.biz.config.security.service.RedisRequestMapService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Map;
import java.util.function.Supplier;
/**
* 사용자 권한 - url 인가
*
* https://docs.spring.io/spring-security/reference/servlet/authorization/architecture.html#authz-voter-adaptation
*/
@Component
@RequiredArgsConstructor
public class AccessDecisionManagerAuthorizationManagerAdapter implements AuthorizationManager<RequestAuthorizationContext> {
public static final String FILTER_SECURITY_INTERCEPTOR_APPLIED = "__spring_security_authorizationManager_filterApplied"; // SecurityContextHolderFilter.class.getName() + ".APPLIED";
private final RedisRequestMapService redisRequestMapService;
private final RoleHierarchy roleHierarchy;
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
try {
HttpServletRequest request = object.getRequest();
if (request == null) {
throw new AccessDeniedException("The request is null.");
}
if (request.getAttribute(FILTER_SECURITY_INTERCEPTOR_APPLIED) != null) {
return new AuthorizationDecision(true);
}
Collection<ConfigAttribute> attributes = this.getAttributes(request);
if (attributes == null || attributes.isEmpty()) {
throw new AccessDeniedException("ConfigAttributes is blank.");
}
Authentication auth = authentication.get();
if (auth == null || auth instanceof AnonymousAuthenticationToken) {
throw new AccessDeniedException("No authentication.");
}
Collection<? extends GrantedAuthority> authorities = this.getAuthorities(auth);
for (ConfigAttribute attribute : attributes) {
if (attribute.getAttribute() == null) {
continue;
}
// Attempt to find a matching granted authority.
for (GrantedAuthority authority : authorities) {
if (attribute.getAttribute().equals(authority.getAuthority())) {
request.setAttribute(FILTER_SECURITY_INTERCEPTOR_APPLIED, Boolean.TRUE);
return new AuthorizationDecision(true);
}
}
}
throw new AccessDeniedException("No matching granted authority.");
} catch (AccessDeniedException ex) {
return new AuthorizationDecision(false);
}
}
/**
* 이전에 FilterInvocationSecurityMetadataSource 로 처리했던 로직
*
* @param request
* @return
* @throws IllegalArgumentException
*/
private Collection<ConfigAttribute> getAttributes(HttpServletRequest request) throws IllegalArgumentException {
Collection<ConfigAttribute> result = null;
Map<RequestMatcher, Collection<ConfigAttribute>> requestMap = redisRequestMapService.getRequestMap();
for (Map.Entry<RequestMatcher, Collection<ConfigAttribute>> entry : requestMap.entrySet()) {
if (entry.getKey().matches(request)) {
result = entry.getValue();
break;
}
}
return result;
}
private Collection<? extends GrantedAuthority> getAuthorities(Authentication authentication) {
return roleHierarchy.getReachableGrantedAuthorities(authentication.getAuthorities());
}
}
@Service
@Slf4j
public class RoleHierarchyServiceImpl implements RoleHierarchyService {
@Resource
private AuthorityHierarchyMappingRepositoryCustom authorityHierarchyMappingRepositoryCustom;
@Resource
private RoleHierarchy roleHierarchy;
/**
* 권한 계층 구조를 만듬
*
*/
@Override
public void resetRoleHierarchyData() {
List<AuthorityHierarchyMappingDTO.GetRes> dbInfo = authorityHierarchyMappingRepositoryCustom.findAll(); // 전체 데이터 조회
try{
((RoleHierarchyImpl)roleHierarchy).setHierarchy(this.makeRoleHierarchyStringRepresentation(dbInfo));
} catch (CycleInRoleHierarchyException e){
log.error("##### 권한 계층 구조에 Cycle이 존재함. 관리자 확인 반드시 필요!!! ##### | error msg : {}", e.getMessage());
throw e;
}
}
/**
* 권한 계층 구조를 만듬(overloading)
*
*/
@Override
public void resetRoleHierarchyData(List<AuthorityHierarchyMappingDTO.GetRes> dbInfo){
try{
((RoleHierarchyImpl)roleHierarchy).setHierarchy(this.makeRoleHierarchyStringRepresentation(dbInfo));
} catch (CycleInRoleHierarchyException e){
log.error("##### 권한 계층 구조에 Cycle이 존재함. 관리자 확인 반드시 필요!!! ##### | error msg : {}", e.getMessage());
throw e;
}
}
/**
* cycle hierarchy인지를 검사함.
* - cycle이 존재하면 true / 존재하지 않으면 false
*
* @param parentAuthorityCd
* @param childAuthorityCds
* @return
*/
@Override
public boolean existCycleHierarchy(String parentAuthorityCd, List<String> childAuthorityCds) {
Collection<GrantedAuthority> searchParamList = new HashSet<>();
childAuthorityCds.forEach(childAuthorityCd -> searchParamList.add(new SimpleGrantedAuthority(childAuthorityCd)));
Collection<? extends GrantedAuthority> childAuthorityCdsHasRoleInfoList = roleHierarchy.getReachableGrantedAuthorities(searchParamList); // 추가 하려는 자식권한이 가지는 모든 권한 리스트
Optional<? extends GrantedAuthority> filteredRoleInfo = childAuthorityCdsHasRoleInfoList.stream().filter(info -> StringUtils.equals(parentAuthorityCd, info.getAuthority())).findFirst();
return filteredRoleInfo.isPresent();
}
/**
* spring hierarchy에서 읽을 수 있는 1차원 계층 형태 스트링 데이터를 만듬.
*
* @param hierarchyList
* @return
*/
private String makeRoleHierarchyStringRepresentation(List<AuthorityHierarchyMappingDTO.GetRes> hierarchyList) {
if (hierarchyList.isEmpty()) {
return "";
}
List<String> hierarchyNotationStrList = new ArrayList<>();
hierarchyList.forEach(hierarchyVo -> hierarchyNotationStrList.add(hierarchyVo.getParentAuthorityCd() + " > " + hierarchyVo.getChildAuthorityCd()));
return String.join("\n", hierarchyNotationStrList);
}
}
SecurityContextRepository의 명시적 저장 요구
The Authentication is set on the SecurityContextHolder. Later, if you need to save the SecurityContext so that it can be automatically set on future requests, SecurityContextRepository#saveContext must be explicitly invoked. See the SecurityContextHolderFilter class.
Spring Security 5에서 기본 동작은 SecurityContextPersistenceFilter를 사용하여 SecurityContextRepository에 SecurityContext를 자동으로 저장하는 것이었는데, 6버전에서는 SecurityContextPersistenceFilter가 deprecated 처리되고 SecurityContextHolderFilter가 SecurityContextRepository에서 SecurityContext를 읽고 SecurityContextHolder에 저장하는 것으로 변경되었습니다.
SecurityContext를 요청 간에 지속시키려면 SecurityContextRepository에 SecurityContext를 명시적으로 저장해야 합니다. 이렇게 하면 모호성을 제거하고 필요할 때만 SecurityContextRepository(즉, HttpSession)에 쓰기를 요구하므로 성능이 향상됩니다.
// bean 등록
@Bean
public SecurityContextRepository securityContextRepository() {
return new HttpSessionSecurityContextRepository();
}
// 명시적 저장
SecurityContextHolder.getContext().setAuthentication(newAuthentication);
securityContextRepository.saveContext(SecurityContextHolder.getContext(), request, response);
[Reference]
https://docs.spring.io/spring-security/reference/6.0/migration/servlet/index.html
https://velog.io/@doublive/Spring-Security-6-버전-URL-요청-인가-흐름
https://velog.io/@on5949/SpringSecurity-Authorization-아키텍쳐
https://docs.spring.io/spring-security/reference/6.0/migration/servlet/session-management.html
'spring > spring security' 카테고리의 다른 글
[SpringSecurity5] OAuth2.0로 네이버 로그인 구현 (0) | 2024.10.17 |
---|---|
[Spring Security5] OAuth2 로그인 설정 (0) | 2024.10.17 |
[Spring Security] DaoAuthenticationProvider (0) | 2023.05.09 |
[Spring Security] DelegatingPasswordEncoder와 BCryptPasswordEncoder strength에 따른 수행시간 (0) | 2022.03.20 |
[Spring Security5] 권한 계층구조(roleHierarchy) 설정 (0) | 2021.08.13 |
댓글