FilterSecurityInterceptorAccessDecisionManager 인터페이스ConfigAttribute 인터페이스(SpEL로 정해준 정보가 담기는 곳)AccessDecisionVoter 인터페이스SpEL 표현식 소개커스텀 SpEL 생성 & 적용
인가(Authorization) — 어플리케이션 보안을 이해하는데 두 번째로 중요한 핵신 개념으로(다른 하나는 인증) 권한이 부여된 사용자들만 특정 기능 또는 데이터에 접근을 허용하는 기능이다. 이를 위해 인가 처리는 두 개의 작업으로 구분된다.
- 인증된 사용자와 권한을 매핑해야 함 — Spring Security에서는 보통 역할이라고 함 (예: ROLE_USER, ROLE_ADMIN, ROLE_ANONYMOUS)
- 보호되는 리소스에 대한 권한 확인 — 관리자 권한을 가진 사용자만 관리자 페이지에 접근 가능

FilterSecurityInterceptor
FilterSecurityInterceptor
- 필터 체인 상에서 가장 마지막에 위치하며, 사용자가 갖고 있는 권한과 리소스에서 요구하는 권한을 취합하여 접근을 허용할지 결정함
- 실질적으로 접근 허용 여부 판단은 AccessDecisionManager 인터페이스 구현체에서 이루어짐
- 작동방식
- FilterSecurityInterceptor의
doFilter()
에서invoke()
호출 invoke()
에서 AbstractSecurityInterceptor의beforeInvocation()
호출attemptAuthorization()
에서 AccessDecisionManager의decide()
호출- 해당 메서드에서 이 요청이 통과해도 되는 요청인지 투표함(AccessDecisionVoter)
// FilterSecurityInterceptor public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException { if (isApplied(filterInvocation) && this.observeOncePerRequest) { // filter already applied to this request and user wants us to observe // once-per-request handling, so don't re-do security checking filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse()); return; } // first time this request being called, so perform security checking if (filterInvocation.getRequest() != null && this.observeOncePerRequest) { filterInvocation.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE); } InterceptorStatusToken token = super.beforeInvocation(filterInvocation); try { filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse()); } finally { super.finallyInvocation(token); } super.afterInvocation(token, null); } //beforeInvocation => AbstractSecurityInterceptor protected InterceptorStatusToken beforeInvocation(Object object) { Assert.notNull(object, "Object was null"); if (!this.getSecureObjectClass().isAssignableFrom(object.getClass())) { throw new IllegalArgumentException("Security invocation attempted for object " + object.getClass().getName() + " but AbstractSecurityInterceptor only configured to support secure objects of type: " + this.getSecureObjectClass()); } else { Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object); if (CollectionUtils.isEmpty(attributes)) { Assert.isTrue(!this.rejectPublicInvocations, () -> { return "Secure object invocation " + object + " was denied as public invocations are not allowed via this interceptor. This indicates a configuration error because the rejectPublicInvocations property is set to 'true'"; }); if (this.logger.isDebugEnabled()) { this.logger.debug(LogMessage.format("Authorized public object %s", object)); } this.publishEvent(new PublicInvocationEvent(object)); return null; } else { if (SecurityContextHolder.getContext().getAuthentication() == null) { this.credentialsNotFound(this.messages.getMessage("AbstractSecurityInterceptor.authenticationNotFound", "An Authentication object was not found in the SecurityContext"), object, attributes); } Authentication authenticated = this.authenticateIfRequired(); if (this.logger.isTraceEnabled()) { this.logger.trace(LogMessage.format("Authorizing %s with attributes %s", object, attributes)); } this.attemptAuthorization(object, attributes, authenticated); /* ... */ } private void attemptAuthorization(Object object, Collection<ConfigAttribute> attributes, Authentication authenticated) { try { this.accessDecisionManager.decide(authenticated, object, attributes); } catch (AccessDeniedException var5) { if (this.logger.isTraceEnabled()) { this.logger.trace(LogMessage.format("Failed to authorize %s with attributes %s using %s", object, attributes, this.accessDecisionManager)); } else if (this.logger.isDebugEnabled()) { this.logger.debug(LogMessage.format("Failed to authorize %s with attributes %s", object, attributes)); } this.publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, var5)); throw var5; } }
- 해당 필터가 호출되는 시점에서 사용자는 이미 인증이 완료되고, Authentication 인터페이스의 getAuthorities() 메소드를 통해 인증된 사용자의 권한 목록을 가져올수 있음
- 익명 사용자도 인증이 완료된 것으로 간주하며, ROLE_ANONYMOUS 권한을 갖음
- 보호되는 리소스에서 요구하는 권한 정보는 SecurityMetadataSource 인터페이스를 통해 ConfigAttribute 타입으로 가져옴
- Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);
FilterSecurityInterceptor
: antMatcher로 정의되는 url에 대한 permission을 보고 처리함
MethodSecurityInterceptor
: @PreAuthorize 혹은 @PostAuthorize 와 같은 어노테이션이 붙어있는 메서드에 대해 처리를 함
SecurityMetadataSource
: 권한 판정을 하기 위해서는 Config attribute가 필요한데, 그것을 모아놓은 map임- FilterSecurityInterceptor와 MethodSecurityInterceptor가 각각 다른
SecurityMetadataSource
를 가지게 됨
- MethodSecurityInterceptor를 가능하게 해주는 어노테이션
@EnableGlobalMethodSecurity(prePostEnabled =
true
)
- 해당 어노테이션은 @Configuration 클래스에다가 붙여서 사용해야 함
- 권한 체크는 FilterSecurityInterceptor에서 한번, 그리고 @PreAuthorize 어노테이션이 붙어있는 곳에서 반복적으로 다 체크를 하게 됨


AccessDecisionManager 인터페이스
AccessDecisonManager

- 사용자가 갖고 있는 권한과 리소스에서 요구하는 권한을 확인하고, 사용자가 적절한 권한을 갖고 있지 않다면 접근 거부 처리함
- AccessDecisionVoter 목록을 갖고 있음
- AccessDecisionVoter들의 투표(vote)결과를 취합하고, 접근 승인 여부를 결정하는 3가지 구현체를 제공함
- AffirmativeBased — AccessDecisionVoter가 승인하면 이전에 거부된 내용과 관계없이 접근이 승인됨 (기본값)
- ConsensusBased — 다수의 AccessDecisionVoter가 승인하면 접근이 승인됨
- UnanimousBased — 모든 AccessDecisionVoter가 만장일치로 승인해야 접근이 승인됨

@Override @SuppressWarnings({ "rawtypes", "unchecked" }) public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> attributes) throws AccessDeniedException { int grant = 0; List<ConfigAttribute> singleAttributeList = new ArrayList<>(1); singleAttributeList.add(null); for (ConfigAttribute attribute : attributes) { singleAttributeList.set(0, attribute); for (AccessDecisionVoter voter : getDecisionVoters()) { int result = voter.vote(authentication, object, singleAttributeList); /* ... */ }
CustomAccessDecisionManager & Custom Voter
- CustomAccessDecisionManager 등록 방법 [Custom AccessDecisionVoter in Spring Security-Baeldung]
http.authorizeRequests() .antMatchers("/me").hasAnyRole("USER", "ADMIN") .antMatchers("/admin").access("hasRole('ADMIN') and isFullyAuthenticated()") .anyRequest().permitAll() .accessDecisionManager(new UnanimousBased(List.of( new WebExpressionVoter(), new OddAdminVoter()))) .and()
- Customer Voter 코드
package com.prgms.devcourse.springsecuritymasterclass.config; import org.springframework.security.access.AccessDecisionVoter; import org.springframework.security.access.ConfigAttribute; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.User; import org.springframework.security.web.FilterInvocation; import org.springframework.security.web.util.matcher.RequestMatcher; import javax.servlet.http.HttpServletRequest; import java.util.Collection; import java.util.regex.Matcher; import java.util.regex.Pattern; public class OddAdminVoter implements AccessDecisionVoter<FilterInvocation> { private static final Pattern PATTERN = Pattern.compile("[0-9]$"); private final RequestMatcher requestMatcher; public OddAdminVoter(RequestMatcher requestMatcher) { this.requestMatcher = requestMatcher; } @Override public boolean supports(ConfigAttribute attribute) { return true; } @Override public boolean supports(Class<?> clazz) { return FilterInvocation.class.isAssignableFrom(clazz); } @Override public int vote(Authentication authentication, FilterInvocation object, Collection<ConfigAttribute> attributes) { if(!requiresAuthorization(object.getRequest())) return ACCESS_GRANTED; User user = (User)authentication.getPrincipal(); String username = user.getUsername(); Matcher matcher = PATTERN.matcher(username); if(matcher.find()){ int number = Integer.parseInt(matcher.group()); if(number % 2 != 0){ return ACCESS_GRANTED; } return ACCESS_DENIED; } return ACCESS_ABSTAIN; } private boolean requiresAuthorization(HttpServletRequest request){ return requestMatcher.matches(request); } }
ConfigAttribute 인터페이스(SpEL로 정해준 정보가 담기는 곳)
ConfigAttribute 인터페이스

- WebExpressionConfigAttribute가 ConfigAttribute인터페이스의 구현체 중 하나임
http.authorizeRequests() .antMatchers("/me").hasAnyRole("USER", "ADMIN") .antMatchers("/admin").access("hasRole('ADMIN') and isFullyAuthenticated()") .anyRequest().permitAll()
AccessDecisionVoter 인터페이스
AccessDecisionVoter 인터페이스
- 각각의 AccessDecisionVoter는 접근을 승인할지 거절할지 혹은 보류할지 판단함 (vote 메소드)
int ACCESS_GRANTED = 1; int ACCESS_ABSTAIN = 0; int ACCESS_DENIED = -1; int vote(Authentication authentication, S object, Collection<ConfigAttribute> attributes);
SpEL 표현식 소개
SpEL 표현식 소개
- WebExpressionVoter 구현체
- [Expression-Based Access Control 스프링 문서]
- SpEL 표현식을 사용해 접근 승인 여부에 대한 규칙을 지정할 수 있음
- SpEL 표현식 처리를 위해 DefaultWebSecurityExpressionHandler 그리고 WebSecurityExpressionRoot 구현에 의존함
- DefaultWebSecurityExpressionHandler.createSecurityExpressionRoot() 메소드에서 WebSecurityExpressionRoot 객체를 생성함
- WebSecurityExpressionRoot 클래스는 SpEL 표현식에서 사용할수 있는 다양한 메소드를 제공
커스텀 SpEL 생성 & 적용
SpEL 표현식 커스텀 핸들러 구현
WebSecurityExpressionRoot
를 상속하는 클래스 생성
- Admin 사용자의 로그인 아이디 끝 숫자가 홀수 인 경우 접근 요청을 승인하는 SpEL 표현식을 구현
- admin01 — 접근 허용
- admin02 — 접근 거부
- WebSecurityExpressionRoot를 상속하고, Admin 사용자의 로그인 아이디 끝 숫자가 홀수 인 경우 접근 요청을 승인하는 isOddAdmin() 메소드를 추가함
public class CustomWebSecurityExpressionRoot extends WebSecurityExpressionRoot { static final Pattern PATTERN = Pattern.compile("[0-9]+$"); public CustomWebSecurityExpressionRoot(Authentication a, FilterInvocation fi) { super(a, fi); } public boolean isOddAdmin() { User user = (User) getAuthentication().getPrincipal(); String name = user.getUsername(); Matcher matcher = PATTERN.matcher(name); if (matcher.find()) { int number = toInt(matcher.group(), 0); return number % 2 == 1; } return false; } }
AbstractSecurityExpressionHandler
를 상속하는 클래스 생성. CustomWebSecurityExpressionRoot 객체를 생성하는 CustomWebSecurityExpressionHandler
구현체 (DefaultWebSecurityExpressionHandler의 구현 형태를 보고 참고해서 만들면 됨)
public class CustomWebSecurityExpressionHandler extends AbstractSecurityExpressionHandler<FilterInvocation> { private final AuthenticationTrustResolver trustResolver; private final String defaultRolePrefix; public CustomWebSecurityExpressionHandler(AuthenticationTrustResolver trustResolver, String defaultRolePrefix) { this.trustResolver = trustResolver; this.defaultRolePrefix = defaultRolePrefix; } @Override protected SecurityExpressionOperations createSecurityExpressionRoot(Authentication authentication, FilterInvocation fi) { CustomWebSecurityExpressionRoot root = new CustomWebSecurityExpressionRoot(authentication, fi); root.setPermissionEvaluator(getPermissionEvaluator()); root.setTrustResolver(this.trustResolver); root.setRoleHierarchy(getRoleHierarchy()); root.setDefaultRolePrefix(this.defaultRolePrefix); return root; } }
커스텀 SpEL 표현식 (oddAdmin)을 추가하고, CustomWebSecurityExpressionHandler 를 설정(expresionHandler( ))
- "isFullyAuthenticated() and hasRole('ADMIN') and oddAdmin" — 명시적 로그인을 수행한 Admin 사용자의 로그인 아이디 끝 숫자가 홀수 인 경우 접근 허용
- SpEL에서 isOddAdmin( ), oddAdmin 이 두 방식으로 호출이 가능함
http // ... 생략 ... .authorizeRequests() .antMatchers("/me").hasAnyRole("USER", "ADMIN") .antMatchers("/admin").access("isFullyAuthenticated() and hasRole('ADMIN') and oddAdmin") .anyRequest().permitAll() .expressionHandler(new CustomWebSecurityExpressionHandler(new AuthenticationTrustResolverImpl(), "ROLE_")) .and() // ... 생략 ...
- expressionHandler를 제대로 설정하지 않으면 ExpressionUtils.evaluateAsBoolean() 메소드에서 예외가 발생함
- DEBUG 로그 레벨로는 확인할 수 있으며, ExpressionUtils 클래스에 브레이크 포인트를 걸어두고 확인할 수 있음
org.springframework.expression.spel.SpelEvaluationException: EL1008E: Property or field 'oddAdmin' cannot be found on object of type 'org.springframework.security.web.access.expression.WebSecurityExpressionRoot' - maybe not public or not valid?