본문 바로가기
spring/spring security

Spring Security 5에서 Spring Security 6으로 변경 (URL-권한 인가)

by moonsiri 2024. 4. 30.
728x90
반응형

개요

스프링 부트 3.0부터 스프링 시큐리티 6 버전이 적용되었습니다. 삭제되거나 deprecated된 코드가 많아서 마이그레이션 시 주의할 부분에 대해 알려드리겠습니다.

 

기존 버전 : 5.3.3.RELEASE

최신 버전 : 6.2.4

 

요약

  1. 기존 WebSecurityConfigurationAdapter를 상속받아 세팅하던 방식은 삭제되었고 SecurityFilterChain bean을 스프링 컨테이너에 등록해줘야함.
  2. authorizeRequests() → authorizeHttpRequests()로 변경
  3. antMatchers() → requestMatchers()로 변경
  4. 로그인 페이지 리다이렉트 반복 접근 이슈 발생
  5. @EnableRedisHttpSession → @EnableRedisIndexedHttpSession로 변경
  6. FilterSecurityInterceptor → AuthorizationFilter로 변경

 

상세

AS-IS)

@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);
	}
}

 

TO-BE)

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);
	}

}

 

 

 

[Reference]

https://docs.spring.io/spring-security/reference/5.8/migration/servlet/authorization.html#_permit_forward_when_using_spring_mvc

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-아키텍쳐

728x90
반응형

댓글