공부함

카카오 로그인 본문

스프링

카카오 로그인

찌땀 2024. 8. 2. 17:55

rednose 프로젝트를 진행하면서 카카오 로그인 부분을 맡았고 관련해서 배운 점들을 정리해보겠습니다.

카카오 로그인

출처 : https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api

kakao developers 문서에서 설명하는 흐름은 위와 같습니다. 

먼저 사용자는 프론트엔드에서 제공하는 kakao login 버튼을 눌러 카카오 로그인을 시도합니다.

step1

사용자가 카카오계정 로그인과 동의 화면에서 동의하고 계속하기를 클릭하면 kakao auth server에서 인가코드를 redirect uri로 보내줍니다. 이 redirect uri는 저희가 kakao developers에 앱을 등록하고 정할 수 있습니다. 인가 코드를 받고자 하는 uri를 지정하면 됩니다.

    @AccessibleWithoutLogin
    @GetMapping("/login/kakao")
    public ResponseEntity<Void> kakaoLogin(@RequestParam String code) {
        UserInfo userInfo = authService.getUserInfoFromAuthCode(code);
        IssueTokenResult issueTokenResult = authService.issueTokenWithUserInfo(userInfo);
        return buildLoginResultResponse(issueTokenResult);
    }

redirect uri를 백엔드ip/api/v1/login/kakao 로 지정하고 위와 같이 RequestParam으로 인가코드 (code)를 받습니다. 

 

step2

지정한 uri에 따라 백엔드에서 인가 코드를 받고, kakao auth server로 POST 요청을 보내서 토큰을 발급받습니다. 

여기서 말하는 토큰은 KakaoToken입니다. 

    public UserInfo getUserInfoFromAuthCode(String authCode) {
        KakaoToken token = getToken(authCode);
        return getUserInfo(token);
    }

리다이렉트로 받은 코드를 통해 getToken 메서드를 통해 토큰을 받아옵니다. 

    public KakaoToken getToken(String authCode) {
        MultiValueMap<String, String> requestBody = new LinkedMultiValueMap<>();
        requestBody.add("grant_type", "authorization_code");
        requestBody.add("client_id", clientId);
        requestBody.add("redirect_url", REDIRECT_URL);
        requestBody.add("code", authCode);

        KakaoToken kaKaoToken = webClient.post()
                .uri(KAUTH_GET_TOKEN_URL)
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .body(BodyInserters.fromFormData(requestBody))
                .retrieve()
                .bodyToMono(KakaoToken.class)
                .block();

        return kaKaoToken;
    }

getToken에서는 webClient를 통해 POST 요청을 해 kakaoToken을 받아옵니다. 

 

step3

받아온 kakaoToken을 통해 kakao auth server로 GET 요청을 해 카카오 회원 정보를 얻을 수 있습니다. 

    public UserInfo getUserInfo(KakaoToken kakaoToken) {
        UserInfo userInfo = webClient.get()
                .uri(KAUTH_GET_USER_INFO_URL)
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + kakaoToken.getAccessToken())
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
                .retrieve()
                .bodyToMono(UserInfo.class)
                .block();
        return userInfo;
    }

코드는 다음과 같습니다. kakao에서 제공하는 형태에 맞춰 UserInfo라는 dto를 만들어 사용했습니다. 

@Setter
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class UserInfo {
    private Long id;
    private KaKaoAccount kakaoAccount;

    public Member toMember(String randomNickname) {
        return Member.builder().socialId(id).usable(true).nickname(randomNickname)
                .image(kakaoAccount.getProfile().getProfileImageUrl()).build();
    }
}

@Setter
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class KaKaoAccount {
    private boolean profileImageNeedsAgreement;
    private Profile profile;

}

위와 같이 받아오길 원하는 정보를 kakao developers 문서에서 설명하는 형태에 맞게 만들어주면 됩니다. 

 

자체 로그인 

카카오 로그인을 통해 카카오 회원 정보까지 얻었으면, 자체 로그인을 진행해야 합니다. 

회원 정보를 토대로 이 회원이 우리 서비스에 이미 가입했는지 등의 여부를 파악해 자체 로그인을 jwt를 사용해 진행했습니다. 

    @Transactional
    public IssueTokenResult issueTokenWithUserInfo(UserInfo userInfo) {
        String randomNickname = randomNicknameGenerator.generate();

        Member member = memberRepository.findBySocialId(userInfo.getId())
                .orElseGet(() -> memberRepository.save(userInfo.toMember(randomNickname)));

        Long memberId = member.getId();

        String accessToken = issueAccessToken(memberId);
        String refreshToken = issueRefreshToken(memberId);

        return buildIssueTokenResult(accessToken, refreshToken, member);
    }

socialId는 카카오의 socialId를 의미합니다. socialId를 통해 이 회원이 우리 서비스의 회원인지 파악하고 아닌 경우에만 회원가입을 진행합니다. 

JwtTokenProvider를 만들어 사용했습니다. accessToken은 프론트에서 헤더에 넣어 보내면 id를 꺼내서 식별해야 하기 때문에 claim으로 id를 넣었습니다. refreshToken은 claim을 따로 넣지 않았는데 지금 보니 필요할 거 같네요.. 수정해야겠습니다. 

 

    private ResponseEntity<Void> buildLoginResultResponse(IssueTokenResult issueTokenResult) {
        String REDIRECT_URL = String.format(REDIRECT_URL_FORMAT, FRONT_HOMEPAGE,
                URLEncoder.encode(issueTokenResult.getNickname()), URLEncoder.encode(issueTokenResult.getImage()));
        return ResponseEntity.status(HttpStatus.FOUND)
                .header(HttpHeaders.LOCATION, REDIRECT_URL)
                .header(HttpHeaders.SET_COOKIE,
                        issueTokenResult.getRefreshTokenCookie())
                .header("accessToken", issueTokenResult.getAccessToken())
                .build();
    }

토큰을 발급하면 accessToken은 accessToken이라는 커스텀 헤더에 넣고, refreshToken은 쿠키로 만들어서 쿠키에 담았습니다. 그리고 회원 닉네임과 프로필 이미지 url은 쿼리파라미터에 넣었습니다. 그리고 프론트 홈페이지로 리다이렉트해 주었습니다. 

 

검증

@Component
@RequiredArgsConstructor
@Slf4j
public class AuthInterceptor implements HandlerInterceptor {

    public static final String ACCESS_TOKEN = "accessToken";
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }

        HandlerMethod handlerMethod = (HandlerMethod) handler;

        AccessibleWithoutLogin accessibleWithoutLogin = handlerMethod.getMethodAnnotation(AccessibleWithoutLogin.class);

        // 로그인하지 않아도 되는 메서드 통과
        if (accessibleWithoutLogin != null) {
            return true;
        }

        // 로그인해야 하는 메서드에 대해 jwt 검증
        // 헤더는 Authorization: <type> <credentials> 형태이므로 토큰을 얻기 위해 split
        String accessToken = getAccessTokenFromRequest(request);
        jwtTokenProvider.verifySignature(accessToken);
        return true;
    }

    private String getAccessTokenFromRequest(HttpServletRequest request) {
        String accessToken = request.getHeader(ACCESS_TOKEN);
        if (accessToken == null || accessToken.isBlank()) {
            throw new UnAuthorizedException(AuthErrorCode.NULL_OR_BLANK_TOKEN);
        }
        return accessToken;
    }
}

로그인 검증은 인터셉터를 통해 진행했습니다. 

로그인 하지 않고 사용할 수 있는 api가 훨씬 적기 때문에 @AccessibleWithOutLogin이라는 애노테이션을 만들어 이 애노테이션이 달린 api만 로그인 없이 접근할 수 있게 했습니다. 

veryfitSignature를 통해 토큰을 검증하고 예외 상황에 대해서는 알맞은 예외를 내려주게 했습니다. 

 

@Component
@RequiredArgsConstructor
public class MemberIdArgumentResolver implements HandlerMethodArgumentResolver {

    public static final String ACCESS_TOKEN = "accessToken";
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(MemberId.class) && Long.class.isAssignableFrom(
                parameter.getParameterType());
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        String accessToken = request.getHeader(ACCESS_TOKEN);
        if (accessToken == null || accessToken.isBlank()) {
            throw new UnAuthorizedException(AuthErrorCode.NULL_OR_BLANK_TOKEN);
        }
        return jwtTokenProvider.getMemberId(accessToken);
    }
}

accessToken을 통해 memberId를 얻기 위해서 argumentresolver를 만들어 사용했습니다. 

 

재발급

    @AccessibleWithoutLogin
    @PostMapping("/reissue/kakao")
    public ResponseEntity<Void> kakaoReissue(
            @CookieValue("refreshToken") String refreshToken) {
        IssueTokenResult issueTokenResult = authService.reIssueToken(refreshToken);
        return buildLoginResultResponse(issueTokenResult);
    }

refreshToken은 db에 저장해 놓고 유효하다면 accessToken과 refreshToken을 모두 재발급해주는 방식으로 진행했습니다. 

 

테스트

    @DisplayName("토큰을 재발급 받을 수 있다")
    @Test
    void 토큰_재발급성공() {
        // given
        String refreshToken = jwtTokenProvider.createRefreshToken();
        Member 지담 = MemberFixture.builder().id(1L).build();

        when(memberRepository.findById(지담.getId())).thenReturn(Optional.of(지담));
        when(memberRepository.findByRefreshToken(refreshToken)).thenReturn(Optional.of(지담));

        webTestClient.post().uri("/api/v1/reissue/kakao")
                .cookie("refreshToken", refreshToken)
                .exchange()
                .expectStatus().isFound()
                .expectHeader().value("accessToken", accessToken -> {
                    assertThat(jwtTokenProvider.getMemberId(accessToken)).isEqualTo(지담.getId());
                });
    }
    @DisplayName("토큰 값이 null 또는 blank일 경우 알맞은 예외를 던진다")
    @ParameterizedTest
    @NullAndEmptySource
    void 토큰값널_예외(String accessToken) {
        String refreshToken = jwtTokenProvider.createRefreshToken();
        webTestClient.post()
                .uri("/api/v1/seals")
                .contentType(MediaType.APPLICATION_JSON)
                .header("accessToken",accessToken)
                .header("refreshToken",refreshToken)
                .exchange()
                .expectStatus().isUnauthorized()
                .expectBody()
                .jsonPath("$.code").isEqualTo(AuthErrorCode.NULL_OR_BLANK_TOKEN.toString())
                .jsonPath("$.message").isEqualTo(AuthErrorCode.NULL_OR_BLANK_TOKEN.getMessage());
    }
    @Test
    @DisplayName("accessToken의 claim에 해당하는 id 값을 argumentResolver가 제대로 얻어서 바인딩 해줄 수 있다")
    public void testCancelMembership() {

        // given
        final Long MEMBER_ID = 10L;
        final String ACCESS_TOKEN = jwtTokenProvider.createAccessToken(MEMBER_ID);
        final Member 지담 = MemberFixture.builder().id(MEMBER_ID).build();

        doNothing().when(memberService).delete(MEMBER_ID);
        doNothing().when(memberRepository).delete(지담);
        when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(지담));

        // when
        webTestClient.delete().uri("/api/v1/members")
                .header("accessToken", ACCESS_TOKEN)
                .exchange()
                .expectStatus().isNoContent();

        // then
        verify(memberService, times(1)).delete(MEMBER_ID);
        verify(memberService, times(0)).delete(2L);
    }

다음과 같이 WebClient 테스트로 리졸버, 인터셉터, 재발급 기능 등을 테스트했습니다. 

 

트러블슈팅 

토큰 위치

accessToken과 refreshToken의 위치에 대해 수차례 변경이 있었습니다. 쿠키에 넣어서 보내면 프론트에서는 쿠키에서 값을 꺼낼 수 없고 그대로 다시 넣어서 보내는 것만 가능하다고 해서 보안상 비교적 중요한 refreshToken은 쿠키에, accessToken은 헤더에 넣는 것으로 결론지었습니다.  

data 위치 

로그인 하면서 프론트로 줘야 하는 data인 사용자 닉네임과 이미지 url을 원래는 바디에 내려주면 되지만 문제가 발생했습니다. 바로 리다이렉트 할 때는 바디에 값을 넣을 수 없다는 것입니다. 프론트 홈페이지로 리다이렉트를 해줘야 하기 때문에 고민 끝에 쿼리파라미터로 넣어 주었습니다. 

 

느낀 점

다른 프로젝트를 하면서 팀원이 구현한 로그인에 대한 pr 리뷰를 하면서 이해했다고 생각했는데 막상 제가 구현하려고 하니 기억도 잘 안나고 어려워서 공부를 열심히 했습니다. pr 리뷰를 더 꼼꼼히 해야겠다고 느꼈습니다. 

 

그리고 프론트에 대해서도 공부를 하면 개발하는데 수월하겠다고 생각했습니다. 프론트에서 쿠키의 내용을 꺼낼 수 없다는 것 등을 저는 몰라서 수정사항이 많이 발생했기 때문입니다. 

 

마지막으로 테스트의 중요성을 느꼈습니다. 변경사항이 많았는데 변경할 때마다 테스트를 돌리면서 어디를 수정해야 하는지 쉽게 발견할 수 있어서 좋았습니다. 이래서 테스트를 짜는구나 하고 다시 한번 느꼈습니다. 특히 WebClientTest는 처음 작성해 봤는데 유용했습니다. ^_^