지난 미션 리뷰
Remember-Me 인증에 대해 정리해보기
- RememberMeAuthenticationFilter
- 인증되지 않은 사용자의 HTTP 요청이 remember-me 쿠키(Cookie)를 갖고 있다면, 사용자를 자동으로 인증처리함
- key — remember-me 쿠키에 대한 고유 식별 키
- 미입력시 자동으로 랜덤 텍스트가 입력 됨
- rememberMeParameter — remember-me 쿠키 파라미터명 (기본값 remember-me)
- tokenValiditySeconds — 쿠키 만료 시간 (초 단위)
- alwaysRemember — 항상 remember-me 를 활성화 시킴 (기본값 false)
http // ... 생략 ... .rememberMe() .key("my-remember-me") .rememberMeParameter("remember-me") .tokenValiditySeconds(300) .alwaysRemember(false) .and() // ... 생략 ...
- TokenBasedRememberMeServices — MD5 해시 알고리즘 기반 쿠키 검증
- PersistentTokenBasedRememberMeServices — 외부 데이터베이스에서 인증에 필요한 데이터를 가져오고 검증함
- 사용자마다 고유의 Series 식별자가 생성되고, 인증 시 마다 매번 갱신되는 임의의 토큰 값을 사용하여 보다 높은 보안성을 제공함
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { if (SecurityContextHolder.getContext().getAuthentication() != null) { this.logger.debug(LogMessage .of(() -> "SecurityContextHolder not populated with remember-me token, as it already contained: '" + SecurityContextHolder.getContext().getAuthentication() + "'")); chain.doFilter(request, response); return; } Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response); if (rememberMeAuth != null) { // Attempt authenticaton via AuthenticationManager try { rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth); // Store to SecurityContextHolder SecurityContextHolder.getContext().setAuthentication(rememberMeAuth); onSuccessfulAuthentication(request, response, rememberMeAuth); this.logger.debug(LogMessage.of(() -> "SecurityContextHolder populated with remember-me token: '" + SecurityContextHolder.getContext().getAuthentication() + "'")); if (this.eventPublisher != null) { this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent( SecurityContextHolder.getContext().getAuthentication(), this.getClass())); } if (this.successHandler != null) { this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth); return; } } catch (AuthenticationException ex) { this.logger.debug(LogMessage .format("SecurityContextHolder not populated with remember-me token, as AuthenticationManager " + "rejected Authentication returned by RememberMeServices: '%s'; " + "invalidating remember-me token", rememberMeAuth), ex); this.rememberMeServices.loginFail(request, response); onUnsuccessfulAuthentication(request, response, ex); } } chain.doFilter(request, response); }
- RememberMeAuthenticationToken
- remember-me 기반 Authentication 인터페이스 구현체
- RememberMeAuthenticationToken 객체는 언제나 인증이 완료된 상태만 존재함
- RememberMeAuthenticationProvider
- RememberMeAuthenticationToken 기반 인증 처리를 위한 AuthenticationProvider
- 앞서 remember-me 설정 시 입력한 key 값을 검증함
@Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { if (!supports(authentication.getClass())) { return null; } if (this.key.hashCode() != ((RememberMeAuthenticationToken) authentication).getKeyHash()) { throw new BadCredentialsException(this.messages.getMessage("RememberMeAuthenticationProvider.incorrectKey", "The presented RememberMeAuthenticationToken does not contain the expected key")); } return authentication; }
- 명시적인 로그인 아이디/비밀번호 기반 인증 사용와 권한 구분
- remember-me 기반 인증과 로그인 아이디/비밀번호 기반 인증 결과가 명백히 다르것에 주목
- remember-me 기반 인증 결과 — RememberMeAuthenticationToken
- 로그인 아이디/비밀번호 기반 인증 결과 — UsernamePasswordAuthenticationToken
- remember-me 기반 인증은 로그인 기반 인증 보다 보안상 다소 약한 인증
- 따라서, 모두 동일하게 인증된 사용자라 하더라도 권한을 분리할 수 있음
- isFullyAuthenticated — 명시적인 로그인 아이디/비밀번호 기반으로 인증된 사용자만 접근 가능
@Override public final boolean isFullyAuthenticated() { return !this.trustResolver.isAnonymous(this.authentication) && !this.trustResolver.isRememberMe(this.authentication); }

세션 처리
SecurityContextPersistenceFilter
- SecurityContextRepository 인터페이스 구현체를 통해 사용자의 SecurityContext를 가져오거나 갱신함
- 인증 관련 필터 중 가장 최 상단에 위치 — 이미 인증된 사용자는 다시 로그인할 필요가 없음
- SecurityContext가 존재하지 않는다면, empty SecurityContext를 생성함
- SecurityContextRepository 인터페이스 기본 구현은 Session을 이용하는 HttpSessionSecurityContextRepository 클래스
private SecurityContextRepository repo; private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { // ensure that filter is only applied once per request if (request.getAttribute(FILTER_APPLIED) != null) { chain.doFilter(request, response); return; } request.setAttribute(FILTER_APPLIED, Boolean.TRUE); if (this.forceEagerSessionCreation) { HttpSession session = request.getSession(); if (this.logger.isDebugEnabled() && session.isNew()) { this.logger.debug(LogMessage.format("Created session %s eagerly", session.getId())); } } HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response); SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder); try { SecurityContextHolder.setContext(contextBeforeChainExecution); if (contextBeforeChainExecution.getAuthentication() == null) { logger.debug("Set SecurityContextHolder to empty SecurityContext"); } else { if (this.logger.isDebugEnabled()) { this.logger .debug(LogMessage.format("Set SecurityContextHolder to %s", contextBeforeChainExecution)); } } chain.doFilter(holder.getRequest(), holder.getResponse()); } finally { SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext(); // Crucial removal of SecurityContextHolder contents before anything else. SecurityContextHolder.clearContext(); this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse()); request.removeAttribute(FILTER_APPLIED); this.logger.debug("Cleared SecurityContextHolder to complete request"); } }
SessionManagementFilter
- 세션 고정 보호 (session-fixation protection)
- session-fixation attack — 세션 하이재킹 기법중 하나로 정상 사용자의 세션을 탈취하여 인증을 우회하는 기법
- 인증 전에 발급 받은 세션 ID가 인증 후에도 동일하게 사용되면 발생할 수 있음
- 즉, 인증 전에 사용자가 가지고 있던 세션이 인증 후에는 사용되지 않도록 하면 해당 공격에 효과적으로 대응할 수 있음
- Spring Security에서는 4가지 설정 가능한 옵션을 제공함
- none — 아무것도 하지 않음 (세션을 그대로 유지함)
- newSession — 새로운 세션을 만들고, 기존 데이터는 복제하지 않음
- migrateSession — 새로운 세션을 만들고, 데이터를 모두 복제함
- changeSession — 새로운 세션을 만들지 않지만, session-fixation 공격을 방어함 (단, servlet 3.1 이상에서만 지원)


- 유효하지 않은 세션 감지 시 지정된 URL로 리다이렉트 시킴
- 세션 생성 전략 설정
- IF_REQUIRED — 필요시 생성함 (기본값)
- NEVER — Spring Security에서는 세션을 생성하지 않지만, 세션이 존재하면 사용함
- STATELESS — 세션을 완전히 사용하지 않음 (JWT 인증이 사용되는 REST API 서비스에 적합)
- ALWAYS — 항상 세션을 사용함
- 동일 사용자의 중복 로그인 감지 및 처리
- maximumSessions — 동일 사용자의 최대 동시 세션 갯수
- maxSessionsPreventsLogin — 최대 갯수를 초과하게 될 경우 인증 시도를 차단할지 여부 (기본값 false)
@Override protected void configure(HttpSecurity http) throws Exception { http /** * 세션 관련 설정 */ .sessionManagement() .sessionFixation().changeSessionId() .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .invalidSessionUrl("/") .maximumSessions(1) .maxSessionsPreventsLogin(false) .and() .and() ; }
- AbstractAuthenticationProcessingFilter 객체는 SessionManagementFilter와 동일한 세션 고정 보호, 최대 로그인 세션 제어를 수행함
- 위 두 개의 필터는 SessionAuthenticationStrategy 객체를 공유함
- AbstractAuthenticationProcessingFilter 구현을 보면, 인증 처리가 완료 된 후 SessionAuthenticationStrategy 객체를 통해 필요한 처리를 수행하고 있음
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { // 생략 ... try { Authentication authenticationResult = attemptAuthentication(request, response); if (authenticationResult == null) { // return immediately as subclass has indicated that it hasn't completed return; } this.sessionStrategy.onAuthentication(authenticationResult, request, response); // Authentication success if (this.continueChainBeforeSuccessfulAuthentication) { chain.doFilter(request, response); } successfulAuthentication(request, response, chain, authenticationResult); } // 생략 ... }
인가(Authorization) 처리
인가(Authorization) — 어플리케이션 보안을 이해하는데 두 번째로 중요한 핵신 개념으로(다른 하나는 인증) 권한이 부여된 사용자들만 특정 기능 또는 데이터에 접근을 허용하는 기능이다. 이를 위해 인가 처리는 두 개의 작업으로 구분된다.
- 인증된 사용자와 권한을 매핑해야 함 — Spring Security에서는 보통 역할이라고 함 (예: ROLE_USER, ROLE_ADMIN, ROLE_ANONYMOUS)
- 보호되는 리소스에 대한 권한 확인 — 관리자 권한을 가진 사용자만 관리자 페이지에 접근 가능

FilterSecurityInterceptor
- 필터 체인 상에서 가장 마지막에 위치하며, 사용자가 갖고 있는 권한과 리소스에서 요구하는 권한을 취합하여 접근을 허용할지 결정함
- 실질적으로 접근 허용 여부 판단은 AccessDecisionManager 인터페이스 구현체에서 이루어짐
- 해당 필터가 호출되는 시점에서 사용자는 이미 인증이 완료되고, Authentication 인터페이스의 getAuthorities() 메소드를 통해 인증된 사용자의 권한 목록을 가져올수 있음
- 익명 사용자도 인증이 완료된 것으로 간주하며, ROLE_ANONYMOUS 권한을 갖음
- 보호되는 리소스에서 요구하는 권한 정보는 SecurityMetadataSource 인터페이스를 통해 ConfigAttribute 타입으로 가져옴

AccessDecisionManager 인터페이스
- 사용자가 갖고 있는 권한과 리소스에서 요구하는 권한을 확인하고, 사용자가 적절한 권한을 갖고 있지 않다면 접근 거부 처리함
- AccessDecisionVoter 목록을 갖고 있음
- AccessDecisionVoter들의 투표(vote)결과를 취합하고, 접근 승인 여부를 결정하는 3가지 구현체를 제공함
- AffirmativeBased — AccessDecisionVoter가 승인하면 이전에 거부된 내용과 관계없이 접근이 승인됨 (기본값)
- ConsensusBased — 다수의 AccessDecisionVoter가 승인하면 접근이 승인됨
- UnanimousBased — 모든 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);
- WebExpressionVoter 구현체
- SpEL 표현식을 사용해 접근 승인 여부에 대한 규칙을 지정할 수 있음
- SpEL 표현식 처리를 위해 DefaultWebSecurityExpressionHandler 그리고 WebSecurityExpressionRoot 구현에 의존함
- DefaultWebSecurityExpressionHandler.createSecurityExpressionRoot() 메소드에서 WebSecurityExpressionRoot 객체를 생성함
- WebSecurityExpressionRoot 클래스는 SpEL 표현식에서 사용할수 있는 다양한 메소드를 제공
- SpEL 표현식 커스텀 핸들러 구현
- 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; } }
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; } }
- "isFullyAuthenticated() and hasRole('ADMIN') and oddAdmin" — 명시적 로그인을 수행한 Admin 사용자의 로그인 아이디 끝 숫자가 홀수 인 경우 접근 허용
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() // ... 생략 ...
- 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?
미션
"/admin" URL 접근에 대한 접근 권한 검사를 SpEL 표현식 방식에서 voter 방식으로 변경해보기 (OddAdminVoter 클래스)
- AccessDecisionVoter<FilterInvocation> 인터페이스를 구현하는 OddAdminVoter 클래스 추가
- Admin 사용자의 로그인 아이디 끝 숫자가 홀수 인 경우 접근 허용
- URL이 "/admin" 이 아닌 경우 접근을 승인함
- expressionHandler(expressionHandler()) 부분 삭제 — 기본 expressionHandler를 사용함
- 표현식에서 oddAdmin 부분 삭제 (삭제 후 표현식은 아래와 같음)
antMatchers("/admin").access("isFullyAuthenticated() and hasRole('ADMIN')")
- AccessDecisionManager 인터페이스 구현체 중 UnanimousBased를 사용하도록 설정하고, 아래 voter를 추가
- WebExpressionVoter
- OddAdminVoter