[Spring Security] OAuth의 의존성을 해결하기 위한 통합테스트

김태훈·2023년 11월 23일
0

Spring Security

목록 보기
7/7
post-thumbnail

OAuth의 로그인 과정을 테스트 하는 것은 어렵다.
이는 외부 OAuth 서버와의 통신이 필요하고 우리의 서비스 로직에서 컨트롤할 수 있는 영역이 아니기 때문이다.
그렇다면 이러한 영역을 어떻게 테스트하면 좋을지 고민하다가, 한가지 해결 방법을 도출했다.

Mocking이 필요하다.

OAuth가 담당하는 부분을 Mocking하면 된다. 외부 서버와의 의존성을 없애기 위해 당연한 일이다.
하지만 무엇을 Mocking해야 하는 것일까?

OAuth 서버를 모킹하면 되겠다. -> 근데 OAuth 서버를 어떻게 Mocking하지? -> 너무 어렵다..

이러한 고민을 계속 반복했던 것 같다.
그래서 필자는, Spring Security가 OAuth를 처리하는 내부 과정을 토대로 테스트 코드를 작성하였다. (임시 방편이랄까..?)

1. SecurityConfig

OAuth를 구현하기 위해 Spring Security를 사용한다는 것은 @EnalbeWebSecurity@Configuration 이 붙은 SecurityConfig 정보에서 FilterChain에 다음과 같이 oauth2Login옵션을 설정해줌으로써 시작된다.

HttpSecurity.oauth2Login(oauth2 -> oauth2
	.clientRegistrationRepository(clientRegistrationRepository)
    .userInfoEndpoint(it -> it.userService(oAuthService))
    .successHandler(authenticationSuccessHandler)
    .failureHandler(oAuthAuthenticationFailureHandler))

필자는 위와 같이 설정했다. 순서대로 설명하면 다음과 같다.

  1. ClientRegistrationRepository에서 OAuth관련 Config 정보들을 설정한다.
  2. OAuth서버 (인가 서버)가 Config 정보를 토대로 인가된 정보를 EndPoint로 내려준다. 여기에서 OAuth2UserRequest 객체에 이를 포함시켜준다. Spring Security에서 제공하는 OAuth관련 Service 객체의 구현체가 이러한 객체를 인자로 받게된다.
  3. 해당 Service 구현체에서 반환한 Authentication 정보가 유효한 정보인지 확인하고 성공하면 SuccessHandler로,
  4. 실패하면 FailureHandler로 넘긴다.

그렇다면 mocking해야할 부분은 어디일까? oAuthService부터이다. 그 이후부터는 우리 서비스의 로직으로 컨트롤하는 부분이기 때문이다.
실제 구현 코드를 보자.

2. OAuthService

@Slf4j
@Service
@RequiredArgsConstructor
public class OAuthService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    private final MemberRepository memberRepository;


    @Override
    @Transactional
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2UserService delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);
        String email = oAuth2User.getAttribute("email");
        return getCustomUserDetails(oAuth2User, email);
    }

    @NotNull
    public CustomUserDetails getCustomUserDetails(OAuth2User oAuth2User, String email) {
        String nickname = UUID.randomUUID().toString().substring(0,15);

        if(memberRepository.findNonSocialMemberByEmail(email).isPresent()){
            return exceptionHandlingUserDetails(oAuth2User);
        }
        Optional<SocialMember> socialMember = memberRepository.findSocialMemberByEmail(email);

        if(socialMember.isEmpty()){
            SocialMember savedSocialMember = SocialMember.createSocialMember(email, nickname);
            SaveMemberResponseDto savedResponse = memberRepository.save(savedSocialMember);
            String roles = Role.addRole(Role.getIncludingRoles(savedResponse.getRole()), Role.OAUTH_FIRST_JOIN);// 최초 회원가입을 위한 임시 role 추가
            return new CustomUserDetails(String.valueOf(savedResponse.getId()), roles, oAuth2User.getAttributes());
        }
        else{
            return new CustomUserDetails(String.valueOf(socialMember.get().getUserId()), Role.getIncludingRoles(socialMember.get().getRole()), oAuth2User.getAttributes());
        }
    }

    @NotNull
    private static CustomUserDetails exceptionHandlingUserDetails(OAuth2User oAuth2User) {
        return new CustomUserDetails("0", Role.EXCEPTION.getRoles(), oAuth2User.getAttributes());
    }
}

여기에서 OAuth 서버에서 받아온 OAuth2UserRequest로부터 OAuth2UserService객체의 loadUser 메서드로 인증 객체 OAuth2User 를 생성한다.
그 후, 이 객체에 우리 서비스의 로직이 담긴 getCustomUserDetails 메서드로 인증 객체의 정보를 수정하고 이를 반환한다.

그렇다면 여기서, Mocking할수 있는 것은 OAuth2UserRequest 일 것이다. 해당 객체를 Mocking하고 우리 서비스에서 유효한 CustomUserDetails가 반환되는지 확인하면 될 것이다.

3. AuthenticationSuccessHandler

(어떻게 SuccessHandler로 넘어가는지 설명하는 사진)
이사진을 반드시 기억하자 !
위 과정을 거쳐 SuccessHandler가 동작할 것인지, FailureHandler가 동작할 것인지 달라진다.
https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html

거기까진 OK이다. 하지만, 인증 후의 이루어지는 과정 또한 테스트가 되어야, OAuth 로그인의 로직이 완벽히 테스트 될 수 있을 것이라고 생각했다.

우리 서비스에서는, OAuth를 통한 회원 가입과, OAuth를 통한 로그인 두가지 케이스에 따라 Redirection URL을 따로 두었다. (물론 둘다 AccessToken과 RefreshToken을 반환한다.)
그래서 마지막 성공 후 로직을 검증하기 위해서는 Authentication Success Handler가 잘 동작하는지 확인해야 한다.

@Component
@RequiredArgsConstructor
@Slf4j
public class AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
    private final TokenProvider tokenProvider;
    @Value("${jwt.domain}") private String domain;
    @Value("${oauth-signup-uri}") private String signUpURI;
    @Value("${oauth-signin-uri}") private String signInURI;
    @Value("${oauth-login-uri}") private String loginURI;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        String accessToken = tokenProvider.createAccessToken(authentication);
        String refreshToken = tokenProvider.createRefreshToken(authentication);

        // 일반 로그인 성공 로직이 있었음 (생략)

        resolveResponseCookieByOrigin(request, response, accessToken, refreshToken);
        response.sendRedirect(redirectUriByFirstJoinOrNot(authentication));

    }

    private static void makeSuccessResponseBody(HttpServletResponse response) throws IOException {
        String successResponse = convertSuccessObjectToString();
        response.setStatus(response.SC_OK);
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().write(successResponse);
    }

    private static String convertSuccessObjectToString() throws JsonProcessingException {
        ObjectMapper objectMapper = new ObjectMapper();
        IsSuccessResponseDto isSuccessResponseDto = new IsSuccessResponseDto(true, "로그인에 성공하였습니다.");
        String successResponse = objectMapper.writeValueAsString(isSuccessResponseDto);
        return successResponse;
    }

    private void resolveResponseCookieByOrigin(HttpServletRequest request, HttpServletResponse response, String accessToken, String refreshToken){
        if(request.getServerName().equals("localhost") || request.getServerName().equals("dev.inforum.me")){
            addCookie(accessToken, refreshToken, response,false);
        }
        else{
            addCookie(accessToken, refreshToken, response,true);
        }
    }

    private void addCookie(String accessToken, String refreshToken, HttpServletResponse response,boolean isHttpOnly) {
        String accessCookieString = makeAccessCookieString(accessToken, isHttpOnly);
        String refreshCookieString = makeRefreshCookieString(refreshToken, isHttpOnly);
        response.setHeader("Set-Cookie", accessCookieString);
        response.addHeader("Set-Cookie", refreshCookieString);
    }

    private String makeAccessCookieString(String token,boolean isHttpOnly) {
        if(isHttpOnly){
            return "accessToken=" + token + "; Path=/; Domain=" + domain + "; Max-Age=3600; SameSite=Lax; HttpOnly; Secure";
        }else{
            return "accessToken=" + token + "; Path=/; Domain=" + domain + "; Max-Age=3600;";
        }
    }

    private String makeRefreshCookieString(String token,boolean isHttpOnly) {
        if(isHttpOnly){
            return "refreshToken=" + token + "; Path=/; Domain=" + domain + "; Max-Age=864000; SameSite=Lax; HttpOnly; Secure";
        }else{
            return "refreshToken=" + token + "; Path=/; Domain=" + domain + "; Max-Age=864000;";
        }
    }

    private String redirectUriByFirstJoinOrNot(Authentication authentication){
        return getRedirectUri(authentication);
    }

    @NotNull
    public String getRedirectUri(Authentication authentication) {
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        Collection<? extends GrantedAuthority> authorities = oAuth2User.getAuthorities();
        if(authorities.stream().filter(o -> o.getAuthority().equals(Role.OAUTH_FIRST_JOIN.getRoles())).findAny().isPresent()){
            return UriComponentsBuilder.fromHttpUrl(signUpURI)
                    .path(authentication.getName())
                    .build().toString();

        }
        else if (authorities.stream().filter(o->o.getAuthority().equals(Role.EXCEPTION.getRoles())).findAny().isPresent()){
            return UriComponentsBuilder.fromHttpUrl(loginURI)
                    .build().toString();
        }
        else{ // non social 로그인의 경우 회원가입한 유저이므로 else문으로 항상 들어감.
            return UriComponentsBuilder.fromHttpUrl(signInURI)
                    .build().toString();
        }
    }
}

onAuthenticationSuccess() 메서드에서 OAuth로그인시 확인해야 할 것은 RedirectUri 이다.
즉, 유효한 Authnetication 객체가 인자로 왔다고 치고, 이에 따른 RedirectUri를 확인하면 되는것이다.
그러면 여기에서는 Authentication객체를 Mocking하는 것이 중요하겠다.

4. 그래서 도출한 테스트 코드 및 정리.

1. OAuthService관련 테스트 코드

@SpringBootTest
@Transactional
class MemberIntegrationTest {

    @Autowired
    MemberRepository memberRepository;
    @Autowired
    MemberService memberService;

    @Autowired
    OAuthService oAuthService;
    // 인증 메일 관련 주입
    @Autowired
    MailService mailService;
    @MockBean
    JavaMailSender mailSender;
    @MockBean
    OAuth2UserRequest oAuth2UserRequest;

    @Autowired
    AuthCodeRepository authCodeRepository;

    NonSocialMember nonSocialMember = NonSocialMember.createNonSocialMember(new MemberSaveDto(
            "userstㄷ12",
            LoginType.NON_SOCIAL,
            "ownest11211ㄷ1@gmail.com",
            "a1234567!"
    ));

    @Nested
    @DisplayName("회원가입")
    class registerUser{
        @Test
        @DisplayName("OAuth 회원가입시 authentication 객체 확인")
        void registerByOAuth(){
            String userEmail = "test@gmail.com";
            OAuth2User mockOAuth2User = mock(OAuth2User.class);
            when(mockOAuth2User.getAttributes()).thenReturn(Map.of());
            assertThat(oAuthService.getCustomUserDetails(mockOAuth2User,userEmail).
                    getAuthorities().stream().map(auth->auth.getAuthority()))
                        .contains(Role.USER.getRoles(),Role.OAUTH_FIRST_JOIN.getRoles());
        }
        @Test
        @DisplayName("OAuth 에 회원가입된 것으로 로그인시 authentication 객체 확인")
        void registerByOAuthWhenAlreadyRegistered(){
            String userEmail = "test@gmail.com";
            memberRepository.save(SocialMember.createSocialMember(userEmail,"nickname"));

            OAuth2User mockOAuth2User = mock(OAuth2User.class);
            when(mockOAuth2User.getAttributes()).thenReturn(Map.of());
            assertThat(oAuthService.getCustomUserDetails(mockOAuth2User,userEmail).
                    getAuthorities().stream().map(auth->auth.getAuthority()))
                    .contains(Role.USER.getRoles());
        }
        @Test
        @DisplayName("OAuth 에 일반 가입된 이메일로 로그인시 authentication 객체 확인")
        void registerByOAuthWhenAlreadyRegisteredByNonSocial(){
            String userEmail = "test@gmail.com";
            memberRepository.save(NonSocialMember.createNonSocialMember(new MemberSaveDto("name",LoginType.NON_SOCIAL,userEmail,"a1234567@")));

            OAuth2User mockOAuth2User = mock(OAuth2User.class);
            when(mockOAuth2User.getAttributes()).thenReturn(Map.of());
            assertThat(oAuthService.getCustomUserDetails(mockOAuth2User,userEmail).getAuthorities().stream().map(o->o.getAuthority())).contains(Role.EXCEPTION.getRoles());
        }
    }
}

공통적으로 OAuth2User 객체를 모킹한 것을 확인할 수 있다.
그 후, 세가지 테스트 코드를 거쳤다.

  1. OAuth로 회원가입시, 알맞은 Authentication 객체가 나오는지?
  2. OAuth로 로그인시, 알맞은 Authentication 객체가 나오는지?
  3. 일반 로그인으로 가입 된 회원인데, OAuth로그인시 잘못된 Authentication 객체가 나오는지?

2. SuccessHandler 테스트

@SpringBootTest
class AuthenticationSuccessHandlerTest {

    @Value("${oauth-signup-uri}") String signUpURI;
    @Value("${oauth-signin-uri}") String signInURI;

    @Autowired
    AuthenticationSuccessHandler authenticationSuccessHandler;
    @Test
    @DisplayName("OAuth 유저 최초로그인시 redirect uri 확인")
    void firstLoginThenRedirectToNicknamePage() throws IOException {
        Authentication mockAuthentication = mock(Authentication.class);
        when(mockAuthentication.getPrincipal()).thenReturn(new CustomUserDetails(String.valueOf(1L), Role.addRole(Role.getIncludingRoles(Role.USER.toString()), Role.OAUTH_FIRST_JOIN), Map.of()));
        when(mockAuthentication.getName()).thenReturn("name");
        Assertions.assertThat(authenticationSuccessHandler.getRedirectUri(mockAuthentication)).isEqualTo(signUpURI+"name");
    }
    @Test
    @DisplayName("OAuth 유저 로그인시 redirect uri 확인")
    void LoginThenRedirectToMainPage() throws IOException {
        Authentication mockAuthentication = mock(Authentication.class);
        when(mockAuthentication.getPrincipal()).thenReturn(new CustomUserDetails(String.valueOf(1L), Role.USER.toString(), Map.of()));
        when(mockAuthentication.getName()).thenReturn("name");
        Assertions.assertThat(authenticationSuccessHandler.getRedirectUri(mockAuthentication)).isEqualTo(signInURI);
    }

}

여기서는 공통적으로 Authentication 클래스를 모킹하였다.
그 후, 두가지 테스트를 거쳤다.

  • 회원가입을 거친 Authentication클래스를 모킹한 후, SuccessHandler에서 알맞은 URL로 이동하는지?
  • 로그인을 거친 Authentication클래스를 모킹한 후, SuccessHandler에서 알맞은 URL로 이동하는지?

3. 테스트 범위

테스트코드를 통해 확인한 flow는 다음과 같다.

  1. OAuthService에서 알맞은 Authentication 객체를 내리는지 확인
  2. (1)에서 도출된 Authentication객체를 모킹하여, 이를 기반으로 알맞은 URI로 이동하는지 확인

4. 아쉬운 점

하지만 여기서 조금 아쉬운 점은,

  1. 만약 유효한 Authentication의 정보가 바뀐다고 하면, 1,2의 테스트 코드를 둘다 손봐야 한다는 점
  2. 1->2로 이루어지는 과정을 하나의 테스트로 포함시키지 못한 점

사실 2번을 하기 위해 고민하다가 해결하지 못했다.
통합테스트를 하기에는, OAuth서버와의 직접적인 통신을 할 수 없기 때문에 불가능하고,
Service와 SuccessHandler를 단위테스트에 모두 포함시킬 수 없으니.. 결국 모킹하여 각각의 로직을 통합테스트 해야겠다는 결론에 이르렀다.

OAuth서버 자체를 Mocking해서 통합테스트를 진행하면 어떨까? 하는 생각이 들었는데.. 고민하다가 시간이 없어서 그냥 넘어가긴 했다. 감이 안온다..

profile
기록하고, 공유합시다

0개의 댓글