Dev_Henry

Oauth2 카카오 소셜로그인 리팩토링 본문

Web/Spring

Oauth2 카카오 소셜로그인 리팩토링

데브헨리 2023. 7. 25. 00:06
728x90

 

+ 내용 추가

프론트에서 카카오 로그인 - 인가코드를 받는 부분까지 처리하고 인가코드를 백엔드에게 넘겨준 후에 나머지 처리하는 로직이 일반적으로 프론트와 백엔드가 분리되어있을 때 사용하는 방식은 맞지만 몇가지 문제가 더있었다.

프론트에서 웹뷰를 사용한다면 이런 로직으로 구현하는데 전혀 문제가 없고 주로 사용하는 방법이다.

그런데 우리 프론트는 flutter를 사용하고 있었고, flutter에서 카카오가 제공해주는 로그인 api를 사용하기 위한 방법은 두가지가 있다.

  1. 웹뷰에서 사용할 수 있는 redirect방식
  2. 네이티브에서 사용할 수 있도록 기능을 제공하는 sdk

아래에 구현한 내용은 일반적으로 1번 방식에서 가능한 방식이다.

네이티브에서 사용하는 sdk는 인가코드만 달랑 받아와서 따로 서버에 전달할 수 없도록 해두었다.

따라서 네이티브에서 카카오 로그인을 하고, 자체 서비스에서 회원관리를 하고싶을 때 권장하는 방식은 아래의 두가지였다.

  1. 플러터에서 소셜로그인 과정과 카카오서버에서 사용자 정보를 받아오는것 까지 모두 처리후에 따로 백엔드 서버에 사용자 정보를 보내서 회원관리
  2. 네이티브에서 로그인부분만 웹뷰처리해서 redirect방식 사용

1번 방법은 아무리 생각해도 이상한게, 소셜로그인 과정이 단순히 사용자 정보를 받아오기 위한 역할밖에 못할 듯 싶었다.

그래서 2번 방법을 선택해야하는데 프론트엔드를 맡은 팀원이 웹뷰를 다뤄본적이 없고, 할 일이 많은데 시간은 촉박해서 2번방법도 힘들다고 생각됐다.

 

그래서 찾은 방법은 token발급까지 플러터에서 처리후에, 토큰을 백엔드에게 보내주는 방식이다.

https://pub.dev/documentation/kakao_flutter_sdk_auth/latest/kakao_flutter_sdk_auth/AuthApi/issueAccessToken.html

sdk 도큐먼트를 뒤져보던중 토큰을 따로 받아올수 있는 방법이 있었기에 가능했다.

아래 작성한 글의 코드중에 인가코드 처리 부분만 빠지고 바로 토큰으로 처리하도록 바꾸면 나머지는 동일하다.


지난학기 캡스톤1 구현 기능 중에 카카오 로그인 부분이 있는데 당시 정상적으로 작동하지 않아서 계속 삽질하다가 꼼수를 써서 구현했었다.

해당부분을 다시 리팩토링 해보려한다.

 

당시 구현 방법을 보면 spring, oauth2, 소셜로그인 모두 처음 접해봐서 동작과정을 잘 몰랐다.

그래서 당시 예제 프로젝트의 코드를 보여주며 설명해주는 책을 한권 사서 책을 따라가면서 공부했었다.

책의 예제코드는 프론트를 따로 분리하지 않고 웹페이지 형태의 view가 Spring프로젝트 내부에 있는 spring MVC 웹페이지의 예제이기 때문에, 시큐리티의 http.oauth2Login()을 통해 카카오 계정 로그인부터 인가코드를 가져오거나 회원 정보를 가져오는 모든 과정을 처리하게 된다.

 

하지만 내가 만들어야하는 프로젝트는 flutter 네이티브를 프론트로 두고, spring은 restController를 사용한 rest api 서버였다.

프론트가 단순 api를 호출하는 네이티브이기 때문에 웹페이지처럼 redirect 시킬 수 없고 네이티브앱, 스프링서버, 카카오서버 모두가 통신을 하면서 동작해야 하기 때문에 이렇게 처리를 하면 안됐다.

스프링은 rest api만 제공해주는 백서버기 때문에 카카오 로그인 페이지를 보여줄수 없다.

일반적으로 이런 상황에서 구현방법은 프론트에서 카카오 로그인 - 인가코드를 받는 부분까지 처리하고 인가코드를 백엔드에게 넘겨준 후에 나머지 처리를 해야했다. (+위의 추가내용 확인)

사실.. 삽질하는동안 이런 방식으로 구현한 코드들을 구글링으로 종종 봤었다..하지만 현재 자체 로그인,토큰 발급 과정을 security filter를 이용해 처리했는데, 위의 방식은 컨트롤러-서비스단에서 인가코드와 함께 요청받아 처리하는 방식이기때문에 통일성이 없어 보였고 소셜로그인도 시큐리티 설정을 통해 처리하고 싶어서 다른방법을 찾아보고 시도하느라 시간을 많이 허비했다.

관련한 내용의 질문에 대한 Chat-gpt의 대답.. 이런 질문과 대답을 지난학기에 했었다면 시간이 훨씬 단축됐을텐데..

 

프론트 구현을 맡은 팀원도 플러터가 처음이었고, 팀원 모두 rest api와 통신하는 프로젝트 자체가 처음이었기 때문에 이런 것들을 모르고 삽질을 많이했다.

웹페이지로 테스트하면 잘 동작하니까 플러터에서 요청하는 방법이 잘못된건가 의심하기도 하고.. 전반적인 동작방식의 이해없이 어디가 문제인지도 모르니 이상한 부분만 찾아보고 수정해보면서 시간낭비를 하다가 평가기간이 다가와 급하게 구현을 해야했기 때문에 잔머리를 굴려서 처리했었다.

 


당시 코드는 아래와 같은 흐름으로 만들었다.

 

  • CustomSecurityConfig
        http.oauth2Login().userInfoEndpoint().userService(customOAuth2UserService()).and().successHandler(authenticationSuccessHandler());
 
  • application-properties
spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.user-name-attribute=id
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me

spring.security.oauth2.client.registration.kakao.client-name=kakao
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.kakao.redirect_uri=${KAKAO_REDIRECT_URL}
spring.security.oauth2.client.registration.kakao.client-id= ${KAKAO_CLIENT_ID}

spring.security.oauth2.client.registration.kakao.client-secret=${KAKAO_CLIENT_SECRET}
spring.security.oauth2.client.registration.kakao.client-authentication-method=POST
spring.security.oauth2.client.registration.kakao.scope=profile_nickname,account_email,gender,birthday
 
  • customOAuth2UserService
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        ClientRegistration clientRegistration = userRequest.getClientRegistration();
        String clientName = clientRegistration.getClientName();

        OAuth2User oAuth2User = super.loadUser(userRequest);
        Map<String, Object> paramMap = oAuth2User.getAttributes();
        Map<String,String> account = new HashMap<>();

        switch (clientName){
            case "kakao":
                account = getKakao(paramMap); //kakao에서 받은 필요한 데이터 파싱해서 가져옴
                break;
        }
        return generateDTO(account, paramMap);
    }
 
  • CustomSocialLoginSuccessHandler
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        log.info("----------------------------------------------------------");
        log.info("CustomLoginSuccessHandler onAuthenticationSuccess ..........");
        log.info(authentication.getPrincipal());

        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        String accessToken = jwtUtil.generateToken(authentication.getName(),authentication.getAuthorities());
        String refreshToken = jwtUtil.generateRefreshToken(authentication.getName());
        redisTemplate.opsForValue().set("RT:"+authentication.getName(),refreshToken, Duration.ofDays(15));
        TokenDTO tokenDTO = TokenDTO.builder().accessToken(accessToken).refreshToken(refreshToken).build();

        response.sendRedirect("http://주소:8080/account/webview.html?accessToken="+tokenDTO.getAccessToken()+"&refreshToken="+tokenDTO.getRefreshToken());

    }
 

열심히 삽질을 하며 엉뚱한 것들만 찾아보다가 떠오른 방법은 '웹페이지로 테스트를 할때는 정상적으로 작동하니까 이부분만 웹뷰처리를 하면 어떻게 일단 돌아가기는 하겠구나'였고, 관련해서 플러터까지 함께 찾아보니 웹뷰 페이지의 js와 플러터가 통신하는 방법이 있었다.

모르는 사람이 봐도 정상적인 방법은 아니고 보안에도 문제가 있어보이지만 일단 동작하도록 만드는게 급해서 js코드만 가진 webview 페이지를 만들고 토큰을 붙여서 클라이언트에게 응답해줬다..


해당 부분을 리팩토링하기 전에 oauth2를 이용한 소셜로그인의 전반적인 동작 과정부터 살펴보자.

  1. 소셜로그인 버튼 클릭
  2. 로그인 팝업 출력, 로그인
  3. 인증서버에서 인가코드 전달
  4. 인가코드를 이용해 토큰발급
  5. 토큰을 이용해 자원서버에 자원요청

일반적인 동작과정을 보면 위와같은 흐름으로 동작하는데 처음에는 이 기본 동작과정부터 이해하기 힘들었다.

내가 만든 스프링서버가 있는데 인증서버와 리소스 서버가 있고, 우리 서비스에서 만들어주는 JWT토큰을 최종적으로 응답해줘야하는데 oauth 동작과정에서도 토큰이 있다. 비슷한게 많아서 뭐가 뭐를 얘기하는건지 헷갈렸다.

위의 그림은 oauth2의 전반적인 흐름의 예시이다.

oauth2의 정의에 대해 구글에 검색해보면 아래와 같다.

OAuth는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준이다

즉 '카카오'에 사용자의 정보가 있는 상태에서, '내가 만든 서비스'가 '카카오에 있는 사용자 정보'를 열람할 수 있도록 허락해주는 방법이다.

현재 spring을 사용해 서버를 개발하고있는 내 입장에서는 view역할을 하는 웹 또는 앱이 client이고, 내가 만든 서버가 server라고 인식하고있는데 소셜로그인 과정에서는 사용자의 정보를 요청하는 나의 스프링서버가 client이고, 사용자 정보를 응답해주는 카카오가 server인 것이다.

 

정리하자면 위의 그림에서 client는 내가 구현중인 spring이고, 인증서버와 리소스서버 둘 다 카카오가 될것이다.

그리고 위의 그림에 나오는 access token은 나의 스프링서버가 카카오의 회원정보에 접근하기 위한 token이다.(유저가 내 서비스를 사용하기 위해 주는 토큰이 아니다!)

또한 이런 카카오를 위한 토큰은 바로 받아오는게 아니라 인가코드라는 중간과정이 있으며 인가코드는 카카오 로그인 페이지에서 로그인에 성공하면 받을 수 있다. ( -> 백엔드 서버의 역할만을 하는 rest api서버는 로그인페이지를 보여줄수 없으니 이부분을 프론트에서 처리해야하는 것)

 


아무튼 내가 리팩토링 해야하는 코드의 결과물은 다음과 같은 흐름으로 동작할 것이다.

 

  • memberController
    @GetMapping("/oauth/{provider}")
    public ResponseEntity<ResultResponse> loginOauth(@PathVariable String provider,@RequestParam String code){
        TokenDTO tokenDTO = oAuth2MemberService.loginOauth(provider,code);
        return ResponseEntity.ok(ResultResponse.of(LOGIN_SUCCESS,tokenDTO));
    }
 

인가코드와 함께 요청을 받기위한 컨트롤러를 만들어준다.

이때 요청 주소는 oauth 설정에도 적는 redirect url이 된다.

 

  • service
    public TokenDTO loginOauth(String providerName, String code) {
        ClientRegistration provider = inMemoryClient.findByRegistrationId(providerName); //provider 가져옴
        KakaoTokenResponse oAuth2Token = getOAuthToken(code, provider); //accessToken 발급
        OAuth2MemberDTO oAuthUser = loginOAuthUser(providerName,provider,oAuth2Token); //로그인

        String accessToken = jwtUtil.generateToken(oAuthUser.getMemberId(),oAuthUser.getRoleSet());
        String refreshToken = jwtUtil.generateRefreshToken(oAuthUser.getMemberId());
        redisTemplate.opsForValue().set("RT:"+oAuthUser.getMemberId(),refreshToken, Duration.ofDays(15));
        TokenDTO tokenDTO = TokenDTO.builder().accessToken(accessToken).refreshToken(refreshToken).build();
        return tokenDTO;
    }
..
..
    private OAuth2MemberDTO loginOAuthUser(String providerName, ClientRegistration provider, KakaoTokenResponse oAuth2Token) {
        Map<String, Object> paramMap = getUserAttribute(provider,oAuth2Token); //토큰을 통해 회원 정보 가져옴
        Map<String,String> account = new HashMap<>();

        switch (providerName){
            case "kakao":
                account = getKakao(paramMap);
                break;
        }
        return generateMember(account);
    }

 

  •  ClientRegistration provider = inMemoryClient.findByRegistrationId(providerName);

-> spring security가 application-properties에 적어둔 oauth2 정보를 바탕으로 관련 객체와 설정을 자동으로 처리 해줬을 것이다.

그리고 그 객체는 'InMemoryClientRegistrationRepository' 라는 공간에 저장된다고 한다.

이곳에서 kakao로 등록해둔 provider를 가져온다.

 

  • KakaoTokenResponse oAuth2Token = getOAuthToken(code, provider); //accessToken 발급

-> 카카오와 통신하여 인가코드를 주고 토큰을 받아온다. WebClient를 사용해 통신했다.

 

  • OAuth2MemberDTO oAuthUser = loginOAuthUser(providerName,provider,oAuth2Token); //로그인

-> getUserAttribute()함수에서 토큰을 통해 카카오 서버에서 회원정보를 가져오고, getKakao()를 통해 필요한 정보만 파싱해서 얻는다.

generateMember()은 가져온 회원정보와 일치하는 계정이 없다면 회원가입을 시키고, 회원정보를 리턴해준다.




이런 과정으로 리팩토링을 마쳤다.

알고나니 별거아닌데 이게 뭐라고 처음 만들때 시간낭비를 그렇게 했는지.. 그래도 덕분에 oAuth2에 대해서도 자세하게 찾아보고, 많은 공부가 됐다.

 

 

jwt토큰 리팩토링 : https://devsungwon.tistory.com/entry/jwt%ED%86%A0%ED%81%B0-%EA%B4%80%EB%A0%A8-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81

refreshToken 리팩토링 : https://devsungwon.tistory.com/entry/Spring-JWT-refreshToken%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EB%AF%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0Redis

 

참고:

https://hudi.blog/oauth-2.0/

 

 

728x90
반응형