OAuth2, Session, 그리고 테스트

김설영·2022년 8월 18일

MyErrorLog

목록 보기
6/6

해당 프로젝트의 소스 코드 바로가기

스프링 부트와 AWS로 혼자 구현하는 웹서비스에서,

HttpSession을 이용하여 현재 인증 및 접속한 유저의 세션을 생성해주는 로직을 구현하는 내용이 있습니다.

저는 위 책을 통해서 게시판을 만든 김에, 특정 유저가 작성한 글은 특정 유저만 수정, 삭제가 가능하도록 로직을 아래의 메커니즘으로 구성하였습니다.

  1. 인증된 유저가 글의 수정 또는 삭제를 요청합니다.

  2. Controller에서 글 정보와 User Session에 대한 정보를 받아오고, Service layer로 글 정보와 세션 정보를 보내줍니다.

  3. Service layer가 정보를 받아 처리합니다.

  4. 세션에서 유저의 Email 주소를 획득하고, 해당 Email 주소를 가진 User를 DB에서 조회합니다.

  5. 조회한 UserPost.getUser()가 동일한지 비교합니다.

  6. 일치하면 글이 수정 또는 삭제되고, 불일치 시 예외가 발생합니다.

위 로직에 대한 대표적인 코드는 아래와 같습니다.

// API controller
public ResponsePost writePost(@Validated @RequestBody RequestAddPost requestAddPost,
                              @LoginUser SessionUser user) {
    return postService.savePost(requestAddPost, user);
}
// 저장 로직. 수정 또는 삭제 로직을 위해서는 Post에 저장한 User를 할당해줘야 합니다.
public ResponsePost savePost(RequestAddPost requestAddPost, SessionUser user) {
    Post post = postRepository.save(requestAddPost.toEntity());
    String categoryName = requestAddPost.getCategoryName();
    User findUser = userRepository.getUserFromEmail(user.getUserEmail())
            .orElseThrow(() -> new IllegalArgumentException("없는 유저입니다."));

    if (!categoryName.isEmpty()) {
        Category category = categoryRepository.findByName(categoryName);

        post.addCategory(category);
        category.addPost(post);
    }

    post.addUser(findUser);
    findUser.addPost(post);

    return new ResponsePost(post);
}

해당 로직은 서버를 띄우고, 직접 조작할 때는 문제 하나 없이 잘 작동이 되었습니다. 하지만, 문제는 테스트 로직에서 발생하였습니다.

테스트 로직에서 발생한 문제


다른 테스트 로직들에서는 문제가 없었지만, 세션 정보를 기반으로 하는 컨트롤러에 대한 테스트로직은 NullPointerException을 발생시켰습니다.

저는 테스트에서 세션을 만들어 넘겨주지 않은 것이 문제라고 판단하였고, MockHttpSession을 이용해서 가짜 세션 객체를 만들어 넘겨주는 방법을 사용하여 해결하려고 했습니다.

MockHttpSession mockHttpSession = new MockHttpSession();
mockHttpSession.setAttribute("user", new SessionUser(user));

mockMvc.perform(post("/write")
                        .contentType(APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(post))
                        .session(mockHttpSession))

그런데, MockHttpSession이 컨트롤러에 넘어가지 않는 것인지, 테스트가 계속해서 실패했습니다.

원인을 파악하기 위해서 Controller layer에 break point를 찍고 디버깅을 해본 결과, 세션이 할당이 되지 않고 null인 것을 확인하였습니다.

일차적으로, MockHttpSession이 잘 생성이 되긴 하는지도 궁금하여, 해당 로직에 break point를 찍고 다시 디버깅을 돌려보았습니다.

위 사진처럼, MockHttpSession은 잘 생성이 되었고, 원하는 attributes도 잘 할당이 된 것을 확인하였습니다. 심지어 MockMvc에도 세션이 잘 넘어갔습니다.

저는 세션이 넘어가지 않는 원인을 파악하기 위해, 책에서 인증 및 인가 문제로 설정해주었던 @WithMockUser(roles = "USER") 애너테이션을 지우고 다시 테스트를 돌려봤습니다.

해당 애너테이션을 제거하면, 세션이 제가 설정한 세션으로 잘 넘어가는 것을 확인하였습니다.

하지만, @WithMockUser(roles = "USER")가 없기 때문에 권한 문제가 발생하여, 302 리디렉션이 발생하였습니다. (스프링 시큐리티가 권한이 없는 상대는 /login 화면으로 리디렉션을 시키기 때문입니다.)

저는 권한 문제도 해결하고, 세션은 제가 원하는 세션을 넣어주기 위해, 폭풍 구글링과 함께 많은 고민을 하기 시작했고, 동시에 폭풍 삽질이 시작되었습니다.

제가 한 삽질이 너무나 많기 때문에 몇 가지만 나열하겠습니다.

삽질 리스트


솔직히, 스프링 시큐리티는 정말 겉핥기 수준으로만 알고 있었습니다. 정말 반성해야 할 부분이자, 삽질의 원인은 "원리를 알지도 못한 채 블로그가 알려주는 것들만 '무지성으로' 시도했다"는 것입니다.

이번 기회로 많이 반성하고, 원리를 모르는 것들은 꼭 공식 문서를 참고하자는 다짐을 하게 되었습니다..🥲

@WithSecurityContext에 사용할 CustomUser 만들어보기

스프링 시큐리티는, SecurityContextHolder에 인증유저를 저장했다가 꺼내서 사용하는 메커니즘이 있습니다.
해당 메커니즘을 이용하여, 인증 객체를 임의로 생성한 뒤 이를 애너테이션화 하여 @WithMockUser(roles = "USER")처럼 사용할 수 있습니다. 자세한 내용은 아래의 검색 키워드로 검색하시면 많이 나옵니다.

  • 검색 키워드 : @WithSecurityContext

가짜 인증 유저 로직은 아래와 같이 구성하였습니다.

public class WithMockCustomUserSecurityFactory implements WithSecurityContextFactory<WithMockCustomUser> {

    @Autowired
    UserRepository userRepository;

    @Override
    public SecurityContext createSecurityContext(WithMockCustomUser customUser) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();

        User user = User.builder()
                .username(customUser.username())
                .userEmail(customUser.userEmail())
                .userPicture(customUser.userPicture())
                .userRole(Role.USER)
                .build();
        userRepository.save(user);

        SessionUser sessionUser = new SessionUser(user);

        Authentication auth = new UsernamePasswordAuthenticationToken(sessionUser, null, AuthorityUtils.createAuthorityList(Role.USER.name()));

        context.setAuthentication(auth);

        return context;
    }
}

구성한 후, 테스트를 수행해보니 세션이 변경되어있음을 확인할 수 있었습니다.

그런데, 치명적인 문제가 있었습니다. 바로, 403 에러를 띄운다는 것이었습니다.

그런데, 더 치명적인 문제가 있었습니다. 바로, 제가 공부를 안했다는 것입니다.

코드만 봐도 알 수 있듯이, 제가 스프링 시큐리티를 제대로 공부하지 않아, 제가 만든 객체가 username/password 인증 객체인지, OAuth2 인증 객체인지도 모르고 그냥 무지성으로 따라한 결과였습니다.

이 삽질은 꽤나 오랫동안(이틀..정도..) 진행하였습니다.
더는 안되겠다 싶어서 스프링 시큐리티 공식문서를 공부하기로 마음을 먹었고, 공부를 시작하여, 공식문서를 여전히 읽고 있습니다.

가짜 인증 객체를 OAuth2 인증 객체로 바꿨는데도 테스트에 실패했다.

Map<String, Object> attr = Map.of(
                "username", customUser.username(),
                "email", customUser.email(),
                "userPicture", customUser.userPicture()
        );

DefaultOAuth2User defaultOAuth2User = new DefaultOAuth2User(AuthorityUtils.createAuthorityList(Role.USER.name()), attr, "username");

Authentication auth = new OAuth2AuthenticationToken(defaultOAuth2User, AuthorityUtils.createAuthorityList(Role.USER.name()), "1");


context.setAuthentication(auth);

return context;

위에서 만든 username/password 방식의 인증 객체를 뒤로하고, 위와 같은 로직으로 OAuth2 인증 객체를 만들어서 적용했습니다.

드디어 CustomUser에 attribute가 세팅되었음을 확인하였습니다. 사실 이 때 테스트가 초록불이 떴으면 좋겠다는 꿈을 꿨습니다..

그래도 403 에러가 뜨는데..?

네. 위 방식으로는 전혀 해결이 되지 않았습니다. 그저 도를 닦는 기분으로 스프링 시큐리티 공식문서나 공부하기로 결심합니다.

그러던 중, "아 어디 블로그에서 @AuthenticationPrincipal이란걸 봤는데.." 라는 기억이 불현듯 스쳐지나갔습니다.

해당 애너테이션은 OAuth2 인증 유저를 파라미터로 받아서 사용할 수 있게 해주는 애너테이션이라고 합니다.

저는, 테스트를 성공시키기 위해서,
기존에 잘 작동하던 방식인 HttpSession을 받아서 사용하는 방법을 갈아 엎고,
@AuthenticationPrincipal을 사용해보기로 결심합니다.


드디어 문제의 해결!


일단, 사용해보기 전에 해당 애너테이션이 어떻게 동작하는지 궁금했습니다.
그래서, Controller에 @AuthenticationPrincipal OAuth2User oAuth2User를 추가하고(아래 코드를 참고해주세요) Controller에 break point를 찍고 디버깅을 돌려보았습니다.

로그인 인증 후, 글 저장 로직을 수행하면 @AuthenticationPrincipal에 의해 OAuth2User에 User 정보가 바인딩 되는 것을 확인하였고,
해당 객체의 attributes를 이용할 수 있겠다 싶어서, 저장 로직에 관여하는 Controller layer에 해당 애너테이션을 추가하고, Service layer에는 @Slf4J를 이용하여 간단히 로그만 확인하는 테스트 메서드를 만들었습니다.

// Controller
@PostMapping("/write")
public ResponsePost writePost(@Validated @RequestBody RequestAddPost requestAddPost,
                              @AuthenticationPrincipal OAuth2User oAuth2User) {
    postService.sessionTest(oAuth2User);
    return postService.savePost(requestAddPost, oAuth2User);
}

// Service logic
public void sessionTest(OAuth2User user) {
    Map<String, Object> attributes = user.getAttributes();
    log.info("email : {}", attributes.get("email"));
    log.info("name : {}", attributes.get("name"));
    log.info("picture : {}", attributes.get("picture"));
}

로직을 구성한 후, 로그인 인증 -> 글 저장 로직을 재수행 해보았습니다.
그 결과, attributes에서 값들을 잘 빼내올 수 있음을 확인하였습니다.

기존에는 HttpSession으로 받아왔던 SessionUser 객체에서 유저의 email 정보를 꺼내서 로직을 수행했었는데,
이를 @AuthenticationPrincipal OAuth2User oAuth2User를 이용하여 유저의 email 정보를 꺼내오는 방식으로 변경하기로 했습니다.

하지만, 해당 방법으로 로직을 변경 할 경우 반드시 고려해야 할 사항이 있었습니다.

"구글, 네이버, 카카오 등 각 API의 attribute key에 할당된 값이 모두 다를 수 있다" 는 것이었습니다.
극단적으로, attributes.get("email")을 했는데 이메일 주소가 아닌 전혀 다른 값이 나올수도 있다는 뜻 입니다.

그래서, 올바른 email주소를 꺼내기 위해 각 API별로 attribute keyvalue의 구성을 살펴보기로 했습니다.

👇👇 구글의 Attribute 구성 👇👇

👇👇 네이버의 Attribute 구성 👇👇

👇👇 카카오의 Attribute 구성 👇👇

운이 좋게도, 구글과 네이버의 email 주소에 대한 Attribute key는 "email"로 같았고, 카카오는 "kakao_account"value 내부내부 키 "email"에 email 주소가 위치하고 있었습니다.

해당 로직은 인증 객체의 정보를 받아서 사용하는 모든 곳에서 공통적으로 사용하게 될 로직일 것 같아, 새로운 클래스에 static 메서드로 만들기로 결정했습니다.

/**
 *@AuthenticationPrincipal로 가져온OAuth2User객체에서 이메일 주소만 뽑아내는 로직
*/

@Getter
public class FindEmailByOAuth2User {

    public static String findEmail(OAuth2User user) {
        Map<String, Object> attributes = user.getAttributes();

        // 1차로 containsKey(email)로 거름 -> 구글, 네이버에서는 이메일 득
        if (attributes.containsKey("email")) {
            return (String) attributes.get("email");
        } else {
            Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
            return (String) kakaoAccount.get("email");
        }
    }
}

저는 해당 로직에 Map객체가 갖고 있는 메서드인 containsKey()를 이용하기로 했습니다.

containsKey(”email”)true일 때, 먼저 해당 key를 가진 OAuth2객체에서 메일 주소 값을 뽑아옵니다. 여기서는 구글과 네이버 계정에서 유저 email 주소를 얻을 수 있습니다.

저의 프로젝트에는 해당 로직이 false일 경우가 인증 계정이 카카오일 때 뿐이라서, 바로 else구문에 카카오 계정의 email 주소를 받아오는 로직을 작성하였습니다.

그 후, 로직이 유저 email 주소를 잘 추출 해내는지 보기 위해 @Slf4j 로그로 확인 해보았습니다.

public void sessionTest(OAuth2User user) {
    String email = findEmail(user);
    log.info("현재 유저의 email 주소 : {}", email);
}

그 결과, 아래와 같이 모든 계정에서 email주소가 잘 추출됨을 확인하였습니다.

테스트를 통해, @AuthenticationPrincipal로 가져온OAuth2User이 제 프로젝트에 사용하기 적합함을 확인할 수 있었고,HttpSession에서 @AuthenticationPrincipal을 이용하는 방식으로 모두 변경 및 적용하였습니다.

서버를 띄우고 기본 CRUD 로직이 잘 되는지 확인한 결과, 잘 수행됨을 확인할 수 있었습니다.
또한, 글 게시자가 아니면 수정과 삭제 로직을 수행할 수 없고, 예외가 발생한다는 제한 조건도 잘 수행되었습니다.

위 내용들도 중요하지만, 사실 가장 중요한건 따로 있습니다.

설정한 방식으로 저의 기존 테스트가 잘 수행되는지 확인 해봐야 합니다.

기존의 테스트 구성대로 하면, 테스트가 전혀 수행되지 않고 똑같은 에러를 반복하였습니다.

이제 스프링 시큐리티의 OAuth2 인증 객체에 대한 가장 알맞는 세팅을 해두었으니, 스프링 시큐리티 공식 문서에 기재되어있는 테스트를 이용해도 될 것 같았습니다.

Testing OAuth 2.0 : Mocking OAuth2 - Configuring Claims 참고

공식문서의 위 두 코드를 참고하여 테스트를 다시 구성하였습니다.

mockMvc.perform(post("/write")
                .contentType(APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(post))
                .with(oauth2Login().attributes(attrs -> attrs.put("email", "email"))))
           	.andExpect(status().isOk())
            .andExpect(jsonPath("$.title").value("제목"))
            .andExpect(jsonPath("$.content").value("내용"))
            .andDo(print());

MockMvc를 구성할 때, .with(oauth2Login())을 추가해줌으로써, 가짜 로그인 사용자를 만들어 주었습니다.

그리고, 가짜 로그인 사용자(oauth2Login())의 attributesattrs -> attrs.put("email", "email") 로직으로 임의로 설정해주었습니다.

이는 어떤 로직이 "email"이라는 attribute key를 요구하면, "email"이라는 값을 반환해주겠다는 의미입니다.

길고긴 삽질의 시간 후...

드디어, 테스트에 감동의 초록불이 떴습니다.. ㅠㅠ
해결하는 데 3일정도 소요된 것 같습니다.. 이녀석 덕분에 출퇴근 하는 내내 스프링 시큐리티 공식문서를 뚫어져라 봤네요..ㅎㅎ

이번 에러를 고생 끝에 해결하면서, 크게 깨달은 점이 있습니다.

  • 왜? 에 대한 원리 파악에는 공식문서만한 것이 없다.
  • 스프링 시큐리티는 너무너무 어렵다. 공부를 많이 해야 할 부분이다. 아직 모르는게 너무 많지만, 이 것만 파고들 수는 없으므로, 꾸준히 공부하도록 하자.
  • 안될 땐 일단 뭐라도 해보자. 안되더라도 해보자. 삽질을 해도 남는것이 많았다.

정말 많은 에러들을 만나고 해결하고 있지만, 이녀석만큼 애먹었던 적은 처음입니다.
하지만, 몰두하고 매달리다보면 언젠가는 해결된다는 것을 배웠습니다.

앞으로도 수많은 에러와 난관이 기다리고 있을 텐데, 그 때 마다 이번에 배운 점들을 잊지 않아야겠습니다.

참고 문헌

스프링 시큐리티 공식문서 5.7.3
토리맘의 한글라이즈 프로젝트 - 스프링 시큐리티
@AuthenticationPrincipal 로그인 정보 받아오기

profile
블로그 이동하였습니당! -> https://kimsy8979.tistory.com/

0개의 댓글