[Spring] 1. 소셜 로그인에 대한 회고

Beanzinu·2022년 10월 5일

스프링부트

목록 보기
5/7
post-thumbnail

💡 소셜 로그인을 구현하기 위해서는 OAuth를 통해 사용자를 인증하고 사용자의 정보를 가져올 필요가 있다.

OAuth 작동 방식은 위의 그림과 같이 간단히 도식화할 수 있고 이를 본 프로젝트에 적용해보았다.

실행 흐름

1. 사용자가 OAuth에 Authroization Code 요청.

  • 로그인 요청은 사용자로부터 이루어지고 요청과 동시에 Authorization Code를 받아올 수 있는 URL으로 페이지를 이동시켰다.
...
const kakao_url = `https://kauth.kakao.com/oauth/authorize?response_type=code
	&client_id=${apikey.rest_api_key}
	&redirect_uri=${redirect_urls.kakaoLogin_redirect_url}`;

// 카카오 인가코드를 받아옴 ( REST_API_KEY , REDIRECT_URL 필요 )
function kakao_getCode(){
    // 현재 페이지를 대체
    window.location.replace(kakao_url);
}
  • client_id의 값은 KAKAO DEVELOPERS에서 관리되고 관련 API 요청들을 수행할 수 있기 때문에 값이 담긴 apikey.js 파일은 같은 reposiotry로 관리하지 않고 개인적으로 관리해 import하여 사용할 수 있게 했다.
  • redirect_url의 경우 사용자가 카카오 로그인을 통해 인증을 완료한 뒤 Authorization Code의 값과 함께 리다이렉트될 수 있는 URI 정보를 넘기면 된다.

2. Redirect URI에서 Authorization Code 조회

  • 인증이 완료된 뒤 Authorization Code는 Redirect URI의 Param을 통해 주어진다.
  • split() 을 통해 쉽게 Authorization Code를 얻을 수 있다.
// Redirect URL 예시
// http://localhost:3000/login/redirect/kakao?code=089pzhwqPtNa21ZVJDi1X--gZDCjUSl8jP-rlEGFd7eO3UvdhzzfiemV-jqrmVaLg_X3EAo9c04AAAGDp4NwPQ

const kakao_code = window.location.search.split("code=")[1] ;

3. Authorization Code을 통해 AccessToken 발급

  • 서버는 원래 Authorization Code를 통해 AccessToken을 발급받고 AccessToken을 통해 해당 사용자의 정보를 조회할 수 있다.
  • 이전에 모바일 애플리케이션을 개발할때 KAKAO SDK 를 이용하여 소셜 로그인을 연동했었는데 Authorization Code를 통해 AccessToken을 발급받지 않고 바로 AccessToken을 발급받는 구조였어서 서버로 전달하기 이전에 AccessToken을 발급받아 전달하는 식으로 구현했다.
axios({
        method: "post",
        url: "https://kauth.kakao.com/oauth/token",
        params: {
            "grant_type" : "authorization_code" ,
            "client_id" : rest_api_key ,
            "redirect_url" : redirect_url ,
            "code" : kakao_code ,
        } 
    })
    .then( async(res) => {

        // 서버에 요청 ( Promise 함수 호출을 통해 jwt 리턴 보장 )
        api.registerUserWithKakao( res.data.access_token )
        .then(async (res) => {
            // SessionStorage에 jwt 저장
            window.sessionStorage.setItem("Auth", res.accessToken);
            window.sessionStorage.setItem("Refresh", res.refreshToken);
            api.updateFcm(window.sessionStorage.getItem("fcm"))
            .then(response =>{
                if( res.isAlreadyRegister ){
                    // 미니홈피페이지로 이동
                    navigate("/", {replace: true});
                }
                else{
                    navigate("/makeusername", {replace: true});
                }
            }).catch(e=>{
                console.log(e)
            })
        });

    })
    .catch( e => {  
        // 로그인 또는 회원가입 실패 
        navigate("/login");
    });
  • client_id : KAKAO DEVEOPERS에서 발급받은 REST API용 고유 키값
  • redirect_url : AccessToken 발급 후 리다이렉트 시킬 URI
  • code : 이전 과정에서 얻은 Authorization Code
  • 성공 시 HTTP Response 안에서 access_token 찾을 수 있음.
body : { 
...
data : { ...,"access_token" : ${액세스_토큰값} },
...
}

4~5. AccessToken을 서버로 전달 ( 로그인 요청 )

// 서버에 요청 ( Promise 함수 호출을 통해 jwt 리턴 보장 )
api.registerUserWithKakao( res.data.access_token )
.then(async (res) => {
    // SessionStorage에 jwt 저장
    window.sessionStorage.setItem("Auth", res.accessToken);
    window.sessionStorage.setItem("Refresh", res.refreshToken);
    api.updateFcm(window.sessionStorage.getItem("fcm"))
    .then(response =>{
        if( res.isAlreadyRegister ){
            // 미니홈피페이지로 이동
            navigate("/", {replace: true});
        }
        else{
            navigate("/makeusername", {replace: true});
        }
    }).catch(e=>{
        console.log(e)
    })
});
function registerUserWithKakao(token){
    return new Promise((resolve,reject) => {
        request({
            method: 'POST' ,
            url: '/api/user/register/kakao',
            headers: { 
                token: token
                // "Access-Control-Allow-Origin" : true
            },
        })
        .then( res => {
            // Jwt 반환
            if ( res.data.statusCode === status.POST_SUCCESS ){
                resolve (res.data.data);
            }
        })
        .catch( e => {
            console.log(e);
            reject();
        })
    });
}
  • Web Server의 코드는 위와 같다. 헤더의 Authorization code를 token 값으로 전달하고 서버에서 성공적으로 응답했을 때 회원가입 또는 로그인이 성공한 경우이기 때문에 FCM을 서버 DB에 갱신한다( api.updateFCM() ) . FCM의 경우 유저에게 알림을 전송하기위해 필요한 정보인데 동일한 유저가 다양한 환경에서 로그인을 시도할 수 있으므로 해당 유저의 브라우저 환경을 로그인마다 갱신해야 한다.
  • 서버에게 accessToken을 전달했을때 응답은 2가지 경우로 나뉜다. 응답을 알아보기 위해서는 먼저 WAS에 있는 비지니스 로직을 살펴볼 필요가 있다. 먼저 해당 Http Request의 요청을 처리하는 Controller 코드다.
    // 카카오 로그인 및 회원가입 요청
    @PostMapping("/api/user/register/kakao")
    public ResponseEntity<ApiResponse> registerUserWithKakao(HttpServletRequest request,HttpServletResponse response) throws ParseException {

        response.addHeader("Access-Control-Expose-Headers", "Auth");
        response.addHeader("Access-Control-Expose-Headers", "Refresh");

        // 카카오로 부터 받아온 정보로 유저로 등록
        Map<String, Object> stringObjectMap = userService.addKakaoUser(request.getHeader("token"));

        String jwt = (String) stringObjectMap.get("accessToken");
        String refresh = (String) stringObjectMap.get("refreshToken");
        boolean isAlreadyRegister = (boolean) stringObjectMap.get("isAlreadyRegister");

        HttpHeaders headers = new HttpHeaders();
        headers.add("Auth", jwt);
        headers.add("Refresh",refresh);

        return new ResponseEntity<>(ApiResponse.response(
                HttpStatusCode.POST_SUCCESS,
                HttpResponseMsg.POST_SUCCESS,
                stringObjectMap), headers, HttpStatus.OK);
    }
  • response.addHeader()의 경우 클라이언트에서 AccessToken 또는 RefreshToken을 각각 “Auth”와 “Refresh” 키를 통해 헤더에 포함시켜 요청하기 위해 필요하다. 클라이언트는 preflight 차원에서 응답으로 Access-Control-Expose_Headers을 확인하여 보낼 수 있는 헤더들을 확인해야하기 때문이다.
  • userService 객체의 addKakaoUser 함수를 호출하여 비지니스 로직을 수행한 뒤 Map 형태의 리턴값을 받고 여기에는 accessToken과 refreshToken 값을 포함한다. 클라이언트에서 헤더로부터 토큰값들을 얻을 수 있도록 Map에서 직접 값을 찾아 Http Headers에 넣어준다.

다음은 서비스단계의 코드다.
이전 컨트롤러에서 addKakaoUser 메소드를 살펴보자.

    @Transactional
    public Map<String,Object> addKakaoUser(String accessToken){

        //받은 String정보를 JSON 객체화
        JSONObject kakaoUserInfoJson = getKakaoUserInfoByAccessToken(accessToken);
        JSONObject userInfoJson = new JSONObject((LinkedHashMap) kakaoUserInfoJson.get("kakao_account"));
        JSONObject profileJson = new JSONObject((LinkedHashMap) userInfoJson.get("profile"));
        // 필요한 정보들
        String email = userInfoJson.getAsString("email");
        String profileUrl = profileJson.getAsString("profile_image_url");

        return addUser(email,profileUrl);

    }
  • userService 내부 private 함수들을 통해 Controller에서 호출한 서비스 메소드를 간략하게 볼 수 있도록 리팩터링을 거쳤다.
  • AccessToken을 통해 가져온 JSONObject 안에서 이메일과 프로필 사진 URL을 추출하였다. 이를 통해 서비스로직은 addUser함수를 호출하며 끝이 난다. 그렇기 때문에 사용자의 인증과 함께 정보들을 어떻게 해결하는지 밑에서 자세히 설명하겠다.
    public JSONObject getKakaoUserInfoByAccessToken(String token)
    {
      String apiURL = "https://kapi.kakao.com/v2/user/me";
    
      MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
      HttpHeaders headers = new HttpHeaders();
      headers.add("Authorization","Bearer "+ token);
    
      HttpEntity<MultiValueMap<String,String>> entity = new HttpEntity<>(params, headers);
      RestTemplate rt = new RestTemplate();
    
      ResponseEntity<JSONObject> userInfoResponse = rt.exchange(
              apiURL,
              HttpMethod.GET,
              entity,
              JSONObject.class
      );
    
      return userInfoResponse.getBody();
    
    }
  • 클라이언트로부터 받은 accessToken을 통해 유저 정보가 담긴 JSONObject를 받을 수 있다. 이 경우 OAuth에 따라 헤더에 넘겨야할 토큰 형태가 다르다. 카카오의 경우 Bearer 토큰임을 나타내도록 “Bearer “와 함께 accessToken을 묶어 전달해야 했다.
  • HTTP 통신은 RestTemplate을 이용했다.
    private Map<String,Object> addUser(String email,String profileUrl){
    
      // 유저가 이미 DB에 존재하는지 확인
      Optional<User> findUser = userRepository.findByEmail(email);
    
      Map<String,Object> map = new HashMap<>();
    
      // 존재 유무 isPresent()로 확인
      if( findUser.isPresent() ){
    
          map.put("accessToken", jwtTokenProvider.createToken(findUser.get().getEmail(),findUser.get().getRole()));
          map.put("refreshToken",jwtTokenProvider.createRefreshToken(findUser.get().getEmail(),findUser.get().getRole()));
          map.put("isAlreadyRegister", true);
    
          return map;
      } else{
          User newUser = User.builder()
                  .email(email)
                  .profileUrl(profileUrl)
                  .repo_url(" ")
                  .role(Role.USER)
                  .nickName(email.split("/")[0])
                  .build();
          User savedUser = userRepository.save(newUser);
    
          map.put("accessToken", jwtTokenProvider.createToken(savedUser.getEmail(), Role.USER));
          map.put("refreshToken",jwtTokenProvider.createRefreshToken(savedUser.getEmail(),Role.USER));
          map.put("isAlreadyRegister", false);
    
          return map;
    }
  • OAuth를 통해 가져온 유저의 이메일로 본 서버의 DB에 조회하면 두가지 경우가 생긴다.

    (1) 회원가입인 경우 ( 특정 유저가 첫 로그인을 한 경우 )

    • Optional 타입의 findUser이 null일 것이다. 그렇기 때문에 새로운 유저 객체에 적절한 정보로 초기화하여 생성하고 이를 DB에 저장하는 userRepository.save() 를 호출한다. 상위 서비스단계에서 Transanctional 어노테이션을 통해 영속성 컨텍스트에 새로운 유저가 유지되고 DB에 commit 또한 가능하다.

    (2) 이미 가입된 경우 ( DB에 유저가 있는 경우 )

    • findUser이 null이 아니다.

    (공통)

    • 결국 컨트롤러 단으로 accessToken과 refreshToken을 발급해준다. 토큰 발급의 경우 기존에 생성해둔 jwtTokenProvider에 회원가입된 유저이든 가입된 유저이든 유저정보를 넘겨 받아올 수 있다. 그리고 자바 컬렉션의 Map을 통해 accessToken,refreshToken,isAlreadyRegister(회원가입/로그인) 을 (Key,Value) 값으로 넣어 Controller 단으로 전달한다.

컨트롤러로 돌아가면 ?

   // 카카오로 부터 받아온 정보로 유저로 등록
   Map<String, Object> stringObjectMap = userService.addKakaoUser(request.getHeader("token"));
   
   String jwt = (String) stringObjectMap.get("accessToken");
   String refresh = (String) stringObjectMap.get("refreshToken");
   boolean isAlreadyRegister = (boolean) stringObjectMap.get("isAlreadyRegister");
   
   HttpHeaders headers = new HttpHeaders();
   headers.add("Auth", jwt);
   headers.add("Refresh",refresh);
   
   return new ResponseEntity<>(ApiResponse.response(
           HttpStatusCode.POST_SUCCESS,
           HttpResponseMsg.POST_SUCCESS,
           stringObjectMap), headers, HttpStatus.OK);
  • stringObjectMap에 위에서 리턴한 결과들이 있을 것이다. 이를 Body 뿐만 아니라 클라이언트에서 헤더에서 사용할 수 있도록 둘 다 담아 리턴하였다. ( 사실 바디나 헤더 둘 중 하나만 리턴했어도 괜찮다. )

6. AccessToken을 클라이언트로 전달

api.registerUserWithKakao( res.data.access_token )
.then(async (res) => {
    // SessionStorage에 jwt 저장
    window.sessionStorage.setItem("Auth", res.accessToken);
    window.sessionStorage.setItem("Refresh", res.refreshToken);
    api.updateFcm(window.sessionStorage.getItem("fcm"))
    .then(response =>{
        if( res.isAlreadyRegister ){
            // 미니홈피페이지로 이동
            navigate("/", {replace: true});
        }
        else{
            navigate("/makeusername", {replace: true});
        }
    }).catch(e=>{
        console.log(e)
    })
});

위의 클라이언트 단 코드를 일부 가져와 다시 살펴보면,

  • “accessToken” : 액세스토큰
  • “refreshToken” : 리프레시토큰
  • “isAlreadyRegister” : 기존 가입여부
  • 이를 통해 브라우저의 sessionStorage 안에 액세스토큰과 리프레시토큰을 저장하고 기존 가입여부에 따라 어디로 redirect할 지 결정할 수 있었다.
profile
당신을 한 줄로 소개해보세요.

0개의 댓글