Spring + React를 이용한 소셜 로그인 연동 (구글, 카카오, 네이버)

SIK407·2023년 9월 17일
1

기술(STACK)

목록 보기
2/6

1. 소셜 로그인 요청, 응답 구조!

위 그림은 이 프로젝트의 로그인 및 사용자 정보를 가져오는 구조를 한번 나타내봤다.
외부의 수단을 통해 인증 및 권한을 부여해야 하기 떄문에 OAuth2 를 사용했다.

OAuth는 외부서비스의 인증 및 권한부여를 관리하는 범용적인 프로토콜

OAuth의 인증 방식이 어떻게 되냐면...
로그인 -> 인가코드 응답 -> 인가 코드로 토큰(Access Token) -> Access Token으로 사용자 정보 요청 및 응답

근데 뭔가 이상하다.... 왜 굳이 로그인 하고 인가 코드로 한번 확인하고, 그 다음에 인가코드로 토큰을 받고, 이 토큰으로 왜 정보를 받을까?

답은 위변조 방지다.

클라이언트가 사용자의 인증 정보(대개는 사용자의 아이디와 비밀번호)를 직접 사용하지 않고, 대신 인가 서버(Authorization Server)와 리소스 서버(Resource Server) 간의 인증을 중개하는 방식을 사용하여 보안성을 강화하기 위한 목적이다.
그니까.... 우리를 단순하게 Id와 Pw을 이용해서 직접 나라고 판단시켜 주는게 아니라! 인가 코드를 통해서 내가 누군지를 판단하게 만들면 보안성이 높아진다.

이 방식을 OAuth2Authorization Code Grant 인증 방식이다.

그 밖에도 OAuth2는 여러가지 인증 방식이 있다.

  1. Authorization Code Grant
  2. Implicit Grant
  3. Resource Owner Password Credentials Grant
  4. Client Credentials

2. 코드!

바로 본론으로 들어가보자. 각 소셜 서버의 디벨로퍼 계정 설정은 다 잘 나와있으니 참고하시고...

우리는 코드만 보여주겠다.

LoginController.java

package com.example.security.Controller;

import com.example.security.Dto.KakaoDataForm;
import com.example.security.OAuth2.GoogleService;
import com.example.security.OAuth2.KakaoService;
import com.example.security.OAuth2.NaverService;
import com.example.security.Service.LoginService;
import com.example.security.Dto.LoginForm;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/login")
@CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true")
@Slf4j
public class LoginController {

    private final LoginService loginService;
    private final KakaoService kakaoService;
    private final NaverService naverService;
    private final GoogleService googleService;

    @PostMapping("/normal")
    public String login(@RequestBody LoginForm loginForm){
        System.out.println("loginForm = " + loginForm);

        return loginService.login(loginForm);
    }

    @GetMapping("/kakao")
    public String KakaoLogin (@RequestParam String code) {
        // System.out.println("code = " + code);

        String token = kakaoService.getKaKaoAccessToken(code);
        KakaoDataForm res = kakaoService.createKakaoUser(token);

        return loginService.KakaoLogin(res);
    }

    @GetMapping("/naver")
    public String NaverLogin (@RequestParam String code, String state) {
        String accessToken = naverService.getNaverAccessToken(code, state);

        return naverService.getUserInfo(accessToken);
    }

    @GetMapping("/google")
    public String GoogleLogin (@RequestParam String code) {
        String accessToken = googleService.getGoogleAccessToken(code);

        return googleService.getUserInfo(accessToken);
    }
}

일단 컨트롤이다.
1. normal: 일반 로그인
2. kakao: 카카오 OAuth 로그인
3. naver: 네이버 OAuth2 로그인
4. google: 구글 OAuth2 로그인

GoogleService.java

package com.example.security.OAuth2;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.reactive.function.client.WebClient;

import java.util.HashMap;
import java.util.Map;

@Service
public class GoogleService {

    @Value("${spring.security.oauth2.client.registration.google.client-id}")
    private String GOOGLE_CLIENT_ID;
    @Value("${spring.security.oauth2.client.registration.google.client-secret}")
    private String GOOGLE_CLIENT_SECRET;
    @Value("${spring.security.oauth2.client.registration.google.redirect-uri}")
    private String LOGIN_REDIRECT_URL;

    public String getGoogleAccessToken(String accessCode) {
        String GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";

        RestTemplate restTemplate = new RestTemplate();
        Map<String, String> params = new HashMap<>();

        params.put("code", accessCode);
        params.put("client_id", GOOGLE_CLIENT_ID);
        params.put("client_secret", GOOGLE_CLIENT_SECRET);
        params.put("redirect_uri", LOGIN_REDIRECT_URL);
        params.put("grant_type", "authorization_code");

        ResponseEntity<String> responseEntity = restTemplate.postForEntity(GOOGLE_TOKEN_URL, params,String.class);

        if (responseEntity.getStatusCode() == HttpStatus.OK) {
            String jsonResponse = responseEntity.getBody();

            try {
                // ObjectMapper를 사용하여 JSON 파싱
                ObjectMapper objectMapper = new ObjectMapper();
                JsonNode jsonNode = objectMapper.readTree(jsonResponse);

                // "access_token" 필드의 값을 추출
                String accessToken = jsonNode.get("access_token").asText();

                return accessToken;
            } catch (Exception e) {
                // 예외 처리
                e.printStackTrace();
            }
        }
        return null;
    }
    public String getUserInfo(String accessToken) {
        WebClient webclient = WebClient.builder()
                .baseUrl("https://www.googleapis.com")
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();

        String response = webclient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("/oauth2/v2/userinfo")
                        .build())
                .header("Authorization", "Bearer " + accessToken)
                .retrieve()
                .bodyToMono(String.class)
                .block();

        return response;
    }
}

kakaoService.java

package com.example.security.OAuth2;

import com.example.security.Dto.KakaoDataForm;
import com.google.gson.JsonParser;
import com.google.gson.JsonElement;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;

@Service
public class KakaoService {

    @Value("${spring.security.oauth2.client.registration.kakao.client-id}")
    private String ClientId;

    public String getKaKaoAccessToken(String code){
        String access_Token="";
        String refresh_Token ="";
        String reqURL = "https://kauth.kakao.com/oauth/token";

        try{
            URL url = new URL(reqURL);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();

            //POST 요청을 위해 기본값이 false인 setDoOutput을 true로
            conn.setRequestMethod("POST");
            conn.setDoOutput(true);

            //POST 요청에 필요로 요구하는 파라미터 스트림을 통해 전송
            BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(conn.getOutputStream()));
            StringBuilder sb = new StringBuilder();
            sb.append("grant_type=authorization_code");
            sb.append("&client_id=").append(ClientId); // TODO REST_API_KEY 입력
            sb.append("&redirect_uri=http://localhost:8080/login/kakao"); // TODO 인가코드 받은 redirect_uri 입력
            sb.append("&code=").append(code);
            bw.write(sb.toString());
            bw.flush();

            //결과 코드가 200이라면 성공
            /*int responseCode = conn.getResponseCode();
            System.out.println("responseCode : " + responseCode);*/
            //요청을 통해 얻은 JSON타입의 Response 메세지 읽어오기
            BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()));
            String line = "";
            String result = "";

            while ((line = br.readLine()) != null) {
                result += line;
            }
            //System.out.println("response body : " + result);

            //Gson 라이브러리에 포함된 클래스로 JSON파싱 객체 생성
            JsonParser parser = new JsonParser();
            JsonElement element = parser.parse(result);

            access_Token = element.getAsJsonObject().get("access_token").getAsString();
            refresh_Token = element.getAsJsonObject().get("refresh_token").getAsString();

            //System.out.println("access_token : " + access_Token);
            //System.out.println("refresh_token : " + refresh_Token);

            br.close();
            bw.close();
        }catch (IOException e) {
            e.printStackTrace();
        }

        return access_Token;
    }

    public KakaoDataForm createKakaoUser(String token) {

        String reqURL = "https://kapi.kakao.com/v2/user/me";

        //access_token을 이용하여 사용자 정보 조회
        try {
            URL url = new URL(reqURL);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();

            conn.setRequestMethod("POST");
            conn.setDoOutput(true);
            conn.setRequestProperty("Authorization", "Bearer " + token); //전송할 header 작성, access_token전송

            //결과 코드가 200이라면 성공
            int responseCode = conn.getResponseCode();
            //System.out.println("responseCode : " + responseCode);

            //요청을 통해 얻은 JSON타입의 Response 메세지 읽어오기
            BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()));
            String line = "";
            String result = "";

            while ((line = br.readLine()) != null) {
                result += line;
            }
            //System.out.println("response body : " + result);

            //Gson 라이브러리로 JSON파싱
            JsonParser parser = new JsonParser();
            JsonElement element = parser.parse(result);

            Long id = element.getAsJsonObject().get("id").getAsLong();
            boolean hasEmail = element.getAsJsonObject().get("kakao_account").getAsJsonObject().get("has_email").getAsBoolean();
            String email = "";
            if (hasEmail) {
                email = element.getAsJsonObject().get("kakao_account").getAsJsonObject().get("email").getAsString();
            }

            String nickname = "test";
            String profile_image = "test2";
            nickname = element.getAsJsonObject().get("properties").getAsJsonObject().get("nickname").getAsString();
            profile_image = element.getAsJsonObject().get("properties").getAsJsonObject().get("profile_image").getAsString();

            System.out.println("id: " + id);
            System.out.println("email: " + email);
            System.out.println("nickname: " + nickname);
            System.out.println("profile_image: " + profile_image);

            br.close();

            KakaoDataForm res = new KakaoDataForm(id, nickname, email, profile_image);
            return res;
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }
}
package com.example.security.OAuth2;

import lombok.extern.slf4j.Slf4j;
import net.minidev.json.JSONObject;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;

import java.util.Map;
@Slf4j
@Service
public class NaverService {

    @Value("${spring.security.oauth2.client.registration.naver.client-id}")
    private String ClientId;

    @Value("${spring.security.oauth2.client.registration.naver.client-secret}")
    private String ClientSecret;

    public String getNaverAccessToken (String code, String state) {
        WebClient webclient = WebClient.builder()
                .baseUrl("https://nid.naver.com")
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();

        JSONObject response = webclient.post()
                .uri(uriBuilder -> uriBuilder
                        .path("/oauth2.0/token")
                        .queryParam("client_id", ClientId)
                        .queryParam("client_secret", ClientSecret)
                        .queryParam("grant_type", "authorization_code")
                        .queryParam("state", state)
                        .queryParam("code", code)
                        .build())
                .retrieve().bodyToMono(JSONObject.class).block();

        // 네이버에서 온 응답에서 토큰을 추출
        return response.get("access_token").toString();
    }

    public String getUserInfo (String accessToken) {
        WebClient webclient = WebClient.builder()
                .baseUrl("https://openapi.naver.com")
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();

        JSONObject response = webclient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("/v1/nid/me")
                        .build())
                .header("Authorization", "Bearer " + accessToken)
                .retrieve()
                .bodyToMono(JSONObject.class).block();

        // 원하는 정보 추출하기
        Map<String, Object> res = (Map<String, Object>) response.get("response");

        return response.get("response").toString();
    }
}

각 서비스의 코드 구조는 거의 유사하다.
두개의 메소드가 있는데,

1. get{소셜 서버}AccessToken
-> 클라이언트에서 받아온 인가코드를 AccessToken으로 달라고 요청하는 메소드

2. getUserInfo
-> 1번 메소드에서 return된 토큰을 이용해서 토큰으로 소셜 서버에 유저 정보를 요청하고, 응답 데이터에서 id값만 추출하거나 혹은 응답 데이터를 출력해보는 메소드다.

아! 참고로 현재 콜백 주소가 백엔드로 되어 있는데, 리액트에 다시 적용해볼 때, 콜백 주소를 변경할 예정이다.
이 방식대로 할려면, 콜백 주소는 프런트엔드(리액트)쪽으로 해야한다!

profile
Spring 백엔드!

0개의 댓글

관련 채용 정보