프로그래밍/Spring

UsernamePasswordFilter 사용자 정의와 세션

수니모스 2021. 3. 2. 19:58

 Spring Security를 적용하다보면 제공되는 메소드만을 이용하는 프로젝트도 많지만 몇몇은 사용자 정의가 필요한 프로젝트가 있죠. 이번 글에는 UsernamePasswordFilter를 사용자 정의하며 겪었던 이슈사항에 대하여 작성하였습니다.

 블로그에 작성된 코드는 샘플로 작성되어 내용 정리에 필요한 내용만 추려 담았습니다.

[이슈 사항]

  • 세션을 1개로 유지하기 위해 sessionManagement().maximumSessions(1) 설정을 하였으나 제대로 적용되지 않는 현상 발생.

 

 

1. 설정을 위한 SecurityConfig 클래스 생성

 Spring Security를 이용하기 위해 @EnableWebSecurity, @Configuration 어노테이션을 추가하여 클래스를 작성하였습니다. protected void configure(HttpSecurity) throws Exception 메소드의 내용은 추후 작성하기로 하고 테스트를 위해 inMemoryAuthentication()에 사용자를 추가하였습니다.

 PasswordEncoder는 다른 설정에 추가하여 @Bean으로 선언한 상태입니다. 코드 간소화를 위해 Lombok을 사용합니다.

@Profile("security")
@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  private final PasswordEncoder passwordEncoder;

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    // TODO something...
  }
  
  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.inMemoryAuthentication()
      .withUser(new LoginUser("user1", passwordEncoder.encode("password1")))
      .withUser(new LoginUser("user2", passwordEncoder.encode("password2")));
  }
}

 

2. CustomUsernamePasswordFilter 클래스 생성

 UsernamePasswordAuthenticationFilter를 확장한 CustomUsernamePasswordFilter를 작성합니다. 해당 클래스에는 내용은 없고 잘 들어왔는지 확인하기 위한 로그를 표시했습니다.

  public static class CustomUsernamePasswordFilter extends UsernamePasswordAuthenticationFilter {
    
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
        throws AuthenticationException {
      
      // TODO something...
      log.info("checked");
      
      return super.attemptAuthentication(request, response);
    }
  }

 

3. CustomUsernamePasswordFilter 객체 생성

 WebSecurityConfigurerAdapter에서 제공하는 authenticationManager() 메소드를 통해 AuthenticationManager를 할당하고 로그인 URL, 파라미터명 등을 설정했습니다. 성공 / 실패의 경우도 SimpleUrlAuthentication... 으로 생성하여 URL로 돌아갈 수 있도록 처리하였습니다.

  @Bean
  public UsernamePasswordAuthenticationFilter authenticationFilter() throws Exception {
    CustomUsernamePasswordFilter filter = new CustomUsernamePasswordFilter();
    filter.setAuthenticationManager(authenticationManager());
    filter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(SecurityConstant.LOGIN_URL, "POST"));
    filter.setUsernameParameter("username");
    filter.setPasswordParameter("password");
    filter.setAuthenticationSuccessHandler(new SimpleUrlAuthenticationSuccessHandler(SecurityConstant.LOGIN_SUCCESS_URL));
    filter.setAuthenticationFailureHandler(new SimpleUrlAuthenticationFailureHandler(SecurityConstant.LOGIN_URL));
    
    return filter;
  }

 

4. configure 내용 추가

 1. 에서 만들어 두었던 SecurityConfig 클래스의 protected void configure(HttpSecurity)에 아래 내용을 추가합니다. LOGIN_URL(GET)에는 단순히 username, password를 입력할 수 있는 페이지고 LOGIN_SUCCESS_URL은 권한이 있는 사용자만 접근할 수 있습니다. 세션은 하나만 유지되도록 maximumSessions(1)을 추가하였고 사용자 정의한 UsernamePasswordAuthenticationFilter를 등록하였습니다. 

    final UsernamePasswordAuthenticationFilter authenticationFilter = authenticationFilter();
    
    http
      .addFilterAt(authenticationFilter, UsernamePasswordAuthenticationFilter.class)
      .exceptionHandling()
        .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint(SecurityConstant.LOGIN_URL))
        .and()
      .authorizeRequests()
        .antMatchers(SecurityConstant.LOGIN_SUCCESS_URL).hasRole(SecurityConstant.ROLE_USER)
        .and()
      .sessionManagement()
        .maximumSessions(1)
        .maxSessionsPreventsLogin(true)
    ;

 

5. 이슈의 발생

 위와 같이 등록하고 프로젝트를 실행한 뒤 서로 다른 브라우저 2개를 열어 같은 아이디로 로그인을 시도하였습니다. 서로 다른 브라우저에서는 쿠키를 공유하지 않기 때문에 기본적으로 설정되어 있는 쿠키 기반 세션은 서로 다른 세션으로 인식할 것입니다. 당연히 MaximumSessions를 1로 설정하였기 때문에 뒤에 접속한 브라우저는 접속이 차단될 것으로 예상했으나 동작하지 않았습니다.

 

 원하는 동작은 세션이 1개만 생성되고 다른 접근은 차단하는 것이기 때문에 수정이 필요한 상황입니다. 수정을 하기 위해 위의 현상이 왜 발생하는지 확인할 필요가 있습니다.

 

6. 이슈 원인 파악

 6-1. Spring에서 제공하는 기본 동작 확인

 우선 Spring에서 제공하는 formLogin() 설정을 하여 동작을 확인하였습니다. 아래의 코드를 protected void configure(HttpSecurity)에 추가하고 등록했던 CustomUsernamePasswordFilter를 주석으로 막고 실행해 보았습니다.

      .formLogin()
        .loginPage(SecurityConstant.LOGIN_URL)
        .loginProcessingUrl(SecurityConstant.LOGIN_URL)
        .defaultSuccessUrl(SecurityConstant.LOGIN_SUCCESS_URL)
        .failureUrl(SecurityConstant.LOGIN_SUCCESS_URL)
        .and()

 2개의 브라우저에서 로그인 시 정상적으로 하나의 브라우저에서 로그인되고 다른 브라우저에서 로그인이 차단되는 것을 확인할 수 있습니다.

 

 6-2. 내부에서 사용하는 UsernamePasswordFilter 설정 확인

 formLogin() 메소드를 사용 시 FormLoginConfigurer 객체를 등록합니다. 그 안을 살펴보면 FormLoginConfigurer가 확장한 추상 클래스인 AbstractAuthenticationFilterConfigurer에서 내부에서 등록하는 UnsernamePasswordFilter의 설정이 있습니다. 아래의 코드에서 authFilter는 UsernamePasswordFilter 객체이며 setSessionAuthenticationStrategy(SessionAuthenticationStrategy) 메소드를 통해 세션 정책을 설정하는 것을 확인할 수 있습니다.

 

	@Override
	public void configure(B http) throws Exception {
		PortMapper portMapper = http.getSharedObject(PortMapper.class);
		if (portMapper != null) {
			authenticationEntryPoint.setPortMapper(portMapper);
		}

		RequestCache requestCache = http.getSharedObject(RequestCache.class);
		if (requestCache != null) {
			this.defaultSuccessHandler.setRequestCache(requestCache);
		}

		authFilter.setAuthenticationManager(http
				.getSharedObject(AuthenticationManager.class));
		authFilter.setAuthenticationSuccessHandler(successHandler);
		authFilter.setAuthenticationFailureHandler(failureHandler);
		if (authenticationDetailsSource != null) {
			authFilter.setAuthenticationDetailsSource(authenticationDetailsSource);
		}
		SessionAuthenticationStrategy sessionAuthenticationStrategy = http
				.getSharedObject(SessionAuthenticationStrategy.class);
		if (sessionAuthenticationStrategy != null) {
			authFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
		}
		RememberMeServices rememberMeServices = http
				.getSharedObject(RememberMeServices.class);
		if (rememberMeServices != null) {
			authFilter.setRememberMeServices(rememberMeServices);
		}
		F filter = postProcess(authFilter);
		http.addFilter(filter);
	}

 

 위의 내용을 통해 CustomUsernamePasswordFilter 객체에도 setSessionAuthenticationStrategy를 등록하면 세션 처리를 할 수 있는 것을 확인할 수 있었습니다. 'UsernamePasswordFilter에서 제공하는 메소드를 조금만 더 잘 살펴봤다면 내부의 내용을 확인하지 않고도 알 수 있었을텐데' 하는 아쉬움은 있었지만 이슈의 원인을 확인하여 이슈를 해결하기 위한 발판을 마련했습니다.

 

7. 이슈의 해결

7-1. 이슈 해결을 위한 고민

 위의 내용대로 setSessionAuthenticationStrategy를 설정하면 되지만 언제나 그렇듯 이슈를 해결하기 위해 여러가지 고민이 생기게 됩니다.

  • SessionAuthenticationStrategy interface를 구현했을 때 들어가는 비용
  • SessionAuthenticationStrategy interface를 구현했을 때 누락할 수 있는 기능
  • protected void configure(HttpSecurity)에 설정한 내용의 활용성

 우선 SessionAuthenticationStrategy interface를 구현할 경우, 소스 작성 시간 + 기본적으로 필요한 기능들에 대한 사전 조사 + 코드 검증 등이 필요할 것입니다. 또한, sessionManagement()에서 기본적으로 제공하는 안정적인 코드를 포기해야하는 이슈가 있습니다.

 UsernamePasswordFilter은 새로운 기능을 추가하기 위해 확장했다고 하지만 기존에 제공하는 기능까지 새로 구현해야 하는지에 대한 의문이 드는게 당연하겠죠. SpringSecurity와 어플리케이션 통합이 원활하지 않을 것 같은 느낌적인 느낌이 드는 것도 있겠습니다.

 

7-2. 이슈 해결 방안 

 위처럼 많은 비용이 필요한 직접 구현 대신, SpringSecurity와 안정적인 통합을 할 수 있는 방안을 확인하기 위해 내부의 코드를 조금 더 살펴보기로 합니다. sessionManagement()에서 생성되는 SessionManagementConfigurer를 보면 아래와 같은 코드를 확인할 수 있습니다.

	private SessionAuthenticationStrategy getSessionAuthenticationStrategy(H http) {
		if (this.sessionAuthenticationStrategy != null) {
			return this.sessionAuthenticationStrategy;
		}
		List<SessionAuthenticationStrategy> delegateStrategies = this.sessionAuthenticationStrategies;
		SessionAuthenticationStrategy defaultSessionAuthenticationStrategy;
		if (this.providedSessionAuthenticationStrategy == null) {
			// If the user did not provide a SessionAuthenticationStrategy
			// then default to sessionFixationAuthenticationStrategy
			defaultSessionAuthenticationStrategy = postProcess(
					this.sessionFixationAuthenticationStrategy);
		}
		else {
			defaultSessionAuthenticationStrategy = this.providedSessionAuthenticationStrategy;
		}
		if (isConcurrentSessionControlEnabled()) {
			SessionRegistry sessionRegistry = getSessionRegistry(http);
			ConcurrentSessionControlAuthenticationStrategy concurrentSessionControlStrategy = new ConcurrentSessionControlAuthenticationStrategy(
					sessionRegistry);
			concurrentSessionControlStrategy.setMaximumSessions(this.maximumSessions);
			concurrentSessionControlStrategy
					.setExceptionIfMaximumExceeded(this.maxSessionsPreventsLogin);
			concurrentSessionControlStrategy = postProcess(
					concurrentSessionControlStrategy);

			RegisterSessionAuthenticationStrategy registerSessionStrategy = new RegisterSessionAuthenticationStrategy(
					sessionRegistry);
			registerSessionStrategy = postProcess(registerSessionStrategy);

			delegateStrategies.addAll(Arrays.asList(concurrentSessionControlStrategy,
					defaultSessionAuthenticationStrategy, registerSessionStrategy));
		}
		else {
			delegateStrategies.add(defaultSessionAuthenticationStrategy);
		}
		this.sessionAuthenticationStrategy = postProcess(
				new CompositeSessionAuthenticationStrategy(delegateStrategies));
		return this.sessionAuthenticationStrategy;
	}

 위의 private SessionAuthenticationStrategy getSessionAuthenticationStrategy(H) 메소드는 public void init(H) 메소드에서 객체를 SharedObject로 등록하는데 사용됩니다. 이것을 이용하면 Spring Security와 자연스럽게 통합할 수 있다는 생각이 듭니다.

 다행스럽게도 postProcess() 메소드를 호출하고 있습니다. postProcess() 메소드는 호출 시점에 등록되어 있는 모든 ObjectPostProcessor의 postProcess 메소드를 호출하게 됩니다. 다시 우리가 작성했던 SecurityConfig로 돌아와서 configure 메소드를 아래와 같이 수정합니다. addObjectPostProcessor() 메소드에 ObjectPostProcessor interface를 구현하여 우리가 생성한 filter에 Spring Security 내부에서 생성한 CompositeSessionAuthenticationStrategy를 할당합니다.

    final UsernamePasswordAuthenticationFilter authenticationFilter = authenticationFilter();
    
    http
//  formLogin() 동작 확인용
//      .formLogin()
//        .loginPage(SecurityConstant.LOGIN_URL)
//        .loginProcessingUrl(SecurityConstant.LOGIN_URL)
//        .defaultSuccessUrl(SecurityConstant.LOGIN_SUCCESS_URL)
//        .failureUrl(SecurityConstant.LOGIN_SUCCESS_URL)
//        .and()
      .addFilterAt(authenticationFilter, UsernamePasswordAuthenticationFilter.class)
      .exceptionHandling()
        .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint(SecurityConstant.LOGIN_URL))
        .and()
      .authorizeRequests()
        .antMatchers(SecurityConstant.LOGIN_SUCCESS_URL).hasRole(SecurityConstant.ROLE_USER)
        .and()
      .sessionManagement()
        .maximumSessions(1)
        .maxSessionsPreventsLogin(true)
        .and()
      .addObjectPostProcessor(new ObjectPostProcessor<CompositeSessionAuthenticationStrategy>() {
        @Override
        public <O extends CompositeSessionAuthenticationStrategy> O postProcess(O object) {
          CompositeSessionAuthenticationStrategy strategy = (CompositeSessionAuthenticationStrategy) object;
          
          authenticationFilter.setSessionAuthenticationStrategy(strategy);
          
          return object;
        }
      })
    ;

 

8. 결과 확인 및 정리

 위의 환경과 동일하게 각각 다른 브라우저를 2개 올린 상태에서 로그인 시도 시 하나만 로그인이 되어 원하는 결과가 나타난 것을 확인하였습니다.

 이번 이슈를 해결하며 빠른 해결을 위해 ObjectPostProcessor를 구현하여 등록하는 방향으로 수정했지만 HttpSecurity.apply(C) 메소드로 사용자 정의한 설정을 하는 방법도 있는 것 같습니다.

 다음에 기회가 된다면 해당 코드를 통해 통합하는 방향을 확인해야겠어요.

 

 개인적인 경험을 바탕으로 작성한 내용이기 때문에 내용 중 이상한 점이나 오류가 있을 경우 댓글 달아주시면 수정하도록 하겠습니다.