[Spring Boot] AuthenticationMangerBuilder의 Mocking이 힘든 이유

이홍준·2023년 8월 4일
0

Trouble Shooting

목록 보기
1/3

JWT 로그인 관련 테스트 코드를 작성하는 중에 발생했던 문제였다. 그냥 실행했을 때에는 나오지 않았던 문제들이었는데, Mockist 방식으로 테스트 코드를 작성해보면서 Spring Security에 대해 부족한 점을 보완해줬던 경험을 알려주고자 한다.

문제 상황

현재 로직을 볼 때 AuthenticationMangerBuilder를 통해 인증된 Authentication 반환하는 형식으로 구현되어있다. 실제 구동할때는 잘 동작하는데, 단위 테스트를 수행할 때는 authBuilder.getObject()에서 NPE(Null Pointer Exception)이 발생한다.

package com.example.demo.user.application.service;

// 로그인 서비스 객체
@RequiredArgsConstructor
@UseCase
@Transactional
class LoginService implements LoginUseCase {
    private final LoadUserPort loadUserPort;
    private final TokenGeneratorPort tokenGeneratorPort;
    private final PasswordEncoderPort passwordEncoderPort;
    private final AuthenticationManagerBuilder authBuilder;
    @Override
    public LoginResponse login(LoginCommand command) {
        User user = loadUserPort.loadByEmail(command.getEmail());
        Optional.of(passwordEncoderPort.matches(command.getPassword(), user.getPassword()))
                .filter(matches -> matches)
                .orElseThrow(UserBadCredentialsException::new);

        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(command.getEmail(), command.getPassword());
        Authentication auth = authBuilder.getObject().authenticate(authToken); //NPE 발생

        Token jwtToken = tokenGeneratorPort.generateToken(auth);

        return LoginResponse.builder()
                .accessToken(jwtToken.getAccessToken())
                .refreshToken(jwtToken.getRefreshToken())
                .expiration(jwtToken.getExpiration().toString())
                .build();
    }
}
java.lang.NullPointerException
	at org.springframework.security.config.annotation.AbstractSecurityBuilder.getObject(AbstractSecurityBuilder.java:50)
	at com.example.demo.user.application.service.LoginServiceTest.loginWithValidCredentialsReturnsLoginResponse(LoginServiceTest.java:71)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	

AuthenticationMangerBuilder를 따로 구현하지 않았는데도 DI가 되는 이유는 실제 환경에서는 auto-config가 되어서 잘 동작하는 것이었다. 그러나 테스트 환경에서는 Mocking했을 때 getObject가 null이기 때문에 수행되지 않는 것이었다.

해결 과정

아래와 같은 방법으로 getObject도 mocking을 해보았다.

when(authBuilder.getObject()).thenReturn(Mockito.mock(AuthenticationManager.class));

그러나 마찬가지로 이렇다. 😢

java.lang.NullPointerException
	at org.springframework.security.config.annotation.AbstractSecurityBuilder.getObject(AbstractSecurityBuilder.java:50)
	at com.example.demo.user.application.service.LoginServiceTest.loginWithValidCredentialsReturnsLoginResponse(LoginServiceTest.java:70)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)

왜 getObejct자체도 mocking이 안되는 걸까?

getObject는 FactoryBean에 속해있지 AuthenticationMangerBuilder 에 존재하지 않는다.


package org.springframework.beans.factory;

import org.springframework.lang.Nullable;

public interface FactoryBean<T> {
	String OBJECT_TYPE_ATTRIBUTE = "factoryBeanObjectType";
	@Nullable
	T getObject() throws Exception;
	@Nullable
	Class<?> getObjectType();
	default boolean isSingleton() {
		return true;
	}

}

그리고 Spring Boot는 실행될 때 Auto-config를 하는데, 그중 하나가UserDetailsServiceAutoConfiguration 이다. 목록을 보면 AuthenticationManager, AuthenticationProvider, UserDetailsService 중에 하나라도 구현된 부분이 없으면 InMemoryUserDetailsManager 에 의해 in-memory로 랜덤하게 “user”라는 이름으로 생성하고 저장된다고 한다.

@AutoConfiguration
@ConditionalOnClass(AuthenticationManager.class)
@ConditionalOnBean(ObjectPostProcessor.class)
@ConditionalOnMissingBean(
		value = { AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class,
				AuthenticationManagerResolver.class },
		type = { "org.springframework.security.oauth2.jwt.JwtDecoder",
				"org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector",
				"org.springframework.security.oauth2.client.registration.ClientRegistrationRepository",
				"org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository" })
public class UserDetailsServiceAutoConfiguration {

	private static final String NOOP_PASSWORD_PREFIX = "{noop}";

	private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$");

	private static final Log logger = LogFactory.getLog(UserDetailsServiceAutoConfiguration.class);

	@Bean
	@Lazy
	public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties,
			ObjectProvider<PasswordEncoder> passwordEncoder) {
		SecurityProperties.User user = properties.getUser();
		List<String> roles = user.getRoles();
		return new InMemoryUserDetailsManager(User.withUsername(user.getName())
			.password(getOrDeducePassword(user, passwordEncoder.getIfAvailable()))
			.roles(StringUtils.toStringArray(roles))
			.build());
	}

	private String getOrDeducePassword(SecurityProperties.User user, PasswordEncoder encoder) {
		String password = user.getPassword();
		if (user.isPasswordGenerated()) {
			logger.warn(String.format(
					"%n%nUsing generated security password: %s%n%nThis generated password is for development use only. "
							+ "Your security configuration must be updated before running your application in "
							+ "production.%n",
					user.getPassword()));
		}
		if (encoder != null || PASSWORD_ALGORITHM_PATTERN.matcher(password).matches()) {
			return password;
		}
		return NOOP_PASSWORD_PREFIX + password;
	}

}

그래서 단위 테스트 환경에서는 전체 애플리케이션 컨텍스트를 로드하지 않기 때문에 Spring Security의 auto-config가 작동하지 않는다. 그리고 FactoryBean 인터페이스를 구현하지 않게 되어 getBean이 적용되지 않는다. 그래서 AuthenticationManager를 생성하지 않게 되어서 authenticate함수를 사용할 수 없게 된다.

조치 결과

AuthenticationManagerBuilder는 구현하기 쉽게 해주지만 아까 말했던 이유들 때문에 Mocking하는데 매우 힘들다. 그래서 특단의 조치를 취하고자 한다.

AuthenticationManagerBuilder를 DI하는 대신에 AuthenticationManager로 주입해준다. 그리고 Mocking 해줌으로써 테스트 코드는 정상 작동 한다.

package com.example.demo.user.application.service;

@DisplayName("로그인 서비스 테스트")
@ExtendWith(MockitoExtension.class)
class LoginServiceTest {
    @InjectMocks
    private LoginService loginService;
    @Mock
    private LoadUserPort loadUserPort;
    @Mock
    private TokenGeneratorPort tokenGeneratorPort;
    @Mock
    private PasswordEncoderPort passwordEncoderPort;

    @Mock
    private AuthenticationManager authManager; // 변경부분

    @DisplayName("로그인 테스트")
    @Test
    void loginWithValidCredentialsReturnsLoginResponse() {
        String email = "zxc123@naver.com";
        String rawPassword = "rawPassword";
        String encodedPassword = "encodedPassword";

        LoginCommand loginCommand = LoginCommand.builder()
                .email(email)
                .password(rawPassword)
                .build();
        // given
        User user = User.builder()
                .id(new User.UserId(1L))
                .name("홍길동")
                .nickname("닉네임")
                .password(encodedPassword)
                .email(email)
                .build();
        Token token = Token.builder()
                .accessToken("Bearer accessToken")
                .refreshToken("refreshToken")
                .expiration(Instant.now().plusMillis(3600000L))
                .build();
        Authentication auth = Mockito.mock(Authentication.class);
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(loginCommand.getEmail(), loginCommand.getPassword());
        when(loadUserPort.loadByEmail(loginCommand.getEmail())).thenReturn(user);
        when(passwordEncoderPort.matches(rawPassword, encodedPassword)).thenReturn(true);
        when(authManager.authenticate(authToken)).thenReturn(auth);  // 변경부분
        when(tokenGeneratorPort.generateToken(any())).thenReturn(token);
        // When
        loginService.login(loginCommand);
        // Then
        verify(loadUserPort, times(1)).loadByEmail(email);
        verify(passwordEncoderPort, times(1)).matches(loginCommand.getPassword(), user.getPassword());

    }
}
@RequiredArgsConstructor
@UseCase
@Transactional
class LoginService implements LoginUseCase {
    private final LoadUserPort loadUserPort;
    private final TokenGeneratorPort tokenGeneratorPort;
    private final PasswordEncoderPort passwordEncoderPort;
    private final AuthenticationManager authenticationManager; // 변경부분

    @Override
    public LoginResponse login(LoginCommand command) {
        User user = loadUserPort.loadByEmail(command.getEmail());
        Optional.of(passwordEncoderPort.matches(command.getPassword(), user.getPassword()))
                .filter(matches -> matches)
                .orElseThrow(UserBadCredentialsException::new);
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(command.getEmail(), command.getPassword());
        Authentication auth = authenticationManager.authenticate(authToken); // 변경부분

        Token jwtToken = tokenGeneratorPort.generateToken(auth);
        return LoginResponse.builder()
                .accessToken(jwtToken.getAccessToken())
                .refreshToken(jwtToken.getRefreshToken())
                .expiration(jwtToken.getExpiration().toString())
                .build();
    }
}

그러나 AuthenticationManager는 인터페이스이기 때문에 Bean을 등록해야 한다.

// Spring Security 5 이상
@Bean
  public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
      return authenticationConfiguration.getAuthenticationManager();
  }

결론

로그인과 같은 인증 과정은 알수록 복잡한 로직이라고 생각한다. 이정도면 어느정도 안다고 생각했는데 깊숙히 들어갈수록 몰랐던 부분이 많았던 부분이다. TDD를 현재 진행 중이었기 때문에 단위 테스트 중에 발생할 문제들로 인해 각 클래스들의 역할들에 대해서 더 깊게 알 수 있는 좋은 계기가 되었다.


References

profile
I'm a web developer.

0개의 댓글