WebClient를 이용한 소셜 로그인 구현

구본식·2022년 8월 6일
3

해당 프로젝트에서는 kakao, naver 플랫폼에 대해서 소셜로그인을 구현하였다.
각 플랫폼의 developer에서의 설정들은 skip하고 코드를 바로 설명하겠다.


1. Kakao와 관련된 코드

1.1 KakaoToken

프로트엔드로 부터 받은 code를 가지고 kakao로 부터 받은 토큰을 담을 객체이다.

@Data
public class KakaoToken {

    private String access_token;
    private String refresh_token;
    private String token_type;
    private int expires_in;

    private String scope;
    private int refresh_token_expires_in;
}

1.2 KakaoProfile

kakao로 부터 받은 사용자 정보를 담을 객체이다.

@Data
public class KakaoProfile {

    public String id; //User의 userid에 들어가기위해서 String으로 선언
    public String connected_at; //
    public Properties properties;
    public KakaoAccount kakao_account;

    public class Properties {
        public String nickname;
        public String profile_image; //이미지 경로 필드1
        public String thumbnail_image;
    }

    @Data
    public class KakaoAccount {
        public Boolean profile_nickname_needs_agreement;
        public Boolean profile_image_needs_agreement;
        public Profile profile;
        public Boolean has_email;
        public Boolean email_needs_agreement;
        public Boolean is_email_valid;
        public Boolean is_email_verified;
        public String email;

        @Data
        public class Profile {
            public String nickname;
            public String thumbnail_image_url;
            public String profile_image_url; //이미지 경로 필드2
            public Boolean is_default_image;
        }
    }
}

각 필드는 kakao developer에 자세히 설명되어있어 skip 하겠다.

1.3 KakaoService

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class KakaoService {

    private final UserRepository userRepository;

    private final String client_id = "xxx";
    private final String client_secret = "xxx";
    private final String redirect_uri = "http://localhost:8880/login/oauth2/code/kakao";
    private final String accessTokenUri = "https://kauth.kakao.com/oauth/token";
    private final String UserInfoUri = "https://kapi.kakao.com/v2/user/me";

    /**
     * 카카오로 부터 엑세스 토큰을 받는 함수
     */
    public KakaoToken getAccessToken(String code) {

        //요청 param (body)
        MultiValueMap<String , String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id",client_id );
        params.add("redirect_uri", redirect_uri);
        params.add("code", code);
        params.add("client_secret", client_secret);


        //request
        WebClient wc = WebClient.create(accessTokenUri);
        String response = wc.post()
                .uri(accessTokenUri)
                .body(BodyInserters.fromFormData(params))
                .header("Content-type","application/x-www-form-urlencoded;charset=utf-8" ) //요청 헤더
                .retrieve()
                .bodyToMono(String.class)
                .block();

        //json형태로 변환
        ObjectMapper objectMapper = new ObjectMapper();
        KakaoToken kakaoToken =null;

        try {
            kakaoToken = objectMapper.readValue(response, KakaoToken.class);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }

        return kakaoToken;
    }

    /**
     * 사용자 정보 가져오기
     */
    public KakaoProfile findProfile(String token) {

        //Http 요청
        WebClient wc = WebClient.create(UserInfoUri);
        String response = wc.post()
                .uri(UserInfoUri)
                .header("Authorization", "Bearer " + token)
                .header("Content-type", "application/x-www-form-urlencoded;charset=utf-8")
                .retrieve()
                .bodyToMono(String.class)
                .block();

        ObjectMapper objectMapper = new ObjectMapper();
        KakaoProfile kakaoProfile = null;

        try {
            kakaoProfile = objectMapper.readValue(response, KakaoProfile.class);

        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
        return kakaoProfile;
    }

    /**
     * 카카오 로그인 사용자 강제 회원가입
     */
    @Transactional
    public User saveUser(String access_token) {
        KakaoProfile profile = findProfile(access_token); //사용자 정보 받아오기
        User user = userRepository.findByUserid(profile.getId());

        //처음이용자 강제 회원가입
        if(user ==null) {
            user = User.builder()
                    .userid(profile.getId())
                    .password(null) //필요없으니 일단 아무거도 안넣음. 원하는데로 넣으면 됌
                    .nickname(profile.getKakao_account().getProfile().getNickname())
                    .profileImg(profile.getKakao_account().getProfile().getProfile_image_url())
                    .email(profile.getKakao_account().getEmail())
                    .roles("USER")
                    .createTime(LocalDateTime.now())
                    .provider("Kakao")
                    .build();

            userRepository.save(user);
        }

        return user;
    }
}
  • getAccessToken
    : 클라이언트에게 받은 code를 사용하여 kakao로 부터 토큰을 받는다. Spring WebClient를 사용하여 카카오 api를 호출하여 필요한 token들을 받는다.

    💡 WebClient란?
    : Spring WebClient란 웹으로 API를 호출하기 위해 사용되는 Http Client 모듈중 하나이다.
    Http Client 모듈중 가장많이 RestTemplate과 WebClient가 사용된다. WebClient방식은 Non-Blocking방식을 사용한다. 그렇기 때문에 요청(Job)보낸후 결과를 기다리지 않고 다른 Job(요청)을 처리하기 때문에 조금더 권장하는 방식이다. 실제로 스프링에서 해당 모듈을 권장하고 있다.

  • findProfile
    : kakao로 부터 받은 access 토큰을 사용하여 사용자 정보를 가져오는 기능을 한다. 기본적인 header의 요청사항들은 kakaoDeveloper에서 자세히 볼수 있다.

  • saveUser
    : kakao 소셜 로그인을 이용하는 사용자에 대해서 처음 서비스를 이용하는 사용자라면 강제 로그인을 하게된다.

2. Naver와 관련된 코드

위에서 구현한 kakao와 거의 동일하여 설명은 skip하도록 하겠다.

2.1 NaverToken

@Data
public class NaverToken {

    private String access_token;
    private String refresh_token;
    private String token_type;
    private int expires_in;

    private String error;
    private String error_description;
}

2.2 NaverProfile

@Data
public class NaverProfile {

    public String resultcode;
    public String message;
    public Response response;

    @Data
    public class Response {
        public String id;
        public String email;
        public String name;
        public String nickname;
        public String profile_image; //사용자 프로필 사진 URL

    }
}

2.3 NaverService

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class NaverService {

    private final UserRepository userRepository;

    private final String client_id = "xxx";
    private final String client_secret = "xxx";
    private final String redirect_uri = "http://localhost:8880/login/oauth2/code/naver";
    private final String accessTokenUri = "https://nid.naver.com/oauth2.0/token";
    private final String UserInfoUri = "https://openapi.naver.com/v1/nid/me";

    /**
     * 카카오로 부터 엑세스 토큰을 받는 함수
     */
    public NaverToken getAccessToken(String code) {

        //요청 param (body)
        MultiValueMap<String , String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id",client_id );
        params.add("redirect_uri",redirect_uri);
        params.add("code", code);
        params.add("client_secret", client_secret);


        //request
        WebClient wc = WebClient.create(accessTokenUri);
        String response = wc.post()
                .uri(accessTokenUri)
                .body(BodyInserters.fromFormData(params))
                .header("Content-type","application/x-www-form-urlencoded;charset=utf-8" ) //요청 헤더
                .retrieve()
                .bodyToMono(String.class)
                .block();

        //json형태로 변환
        ObjectMapper objectMapper = new ObjectMapper();
        NaverToken naverToken =null;

        try {
            naverToken = objectMapper.readValue(response, NaverToken.class);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }

        return naverToken;
    }

    /**
     * 사용자 정보 가져오기
     */
    public NaverProfile findProfile(String token) {

        //Http 요청
        WebClient wc = WebClient.create(UserInfoUri);
        String response = wc.get()
                .uri(UserInfoUri)
                .header("Authorization", "Bearer " + token)
                .header("Content-type", "application/xml;charset=utf-8")
                .retrieve()
                .bodyToMono(String.class)
                .block();

        ObjectMapper objectMapper = new ObjectMapper();
        NaverProfile naverProfile = null;

        try {
            naverProfile = objectMapper.readValue(response, NaverProfile.class);

        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
    }

    /**
     * 카카오 로그인 사용자 강제 회원가입
     */
    @Transactional
    public User saveUser(String access_token) {
        NaverProfile profile = findProfile(access_token); //사용자 정보 받아오기
        User user = userRepository.findByUserid(profile.response.getId());

        //처음이용자 강제 회원가입
        if(user ==null) {
            user = User.builder()
                    .userid(profile.response.getId())
                    .password(null) //필요없으니 일단 아무거도 안넣음. 원하는데로 넣으면 됌
                    .nickname(profile.response.getNickname())
                    .profileImg(profile.response.profile_image)
                    .email(profile.response.email)
                    .roles("USER")
                    .createTime(LocalDateTime.now())
                    .provider("Naver")
                    .build();

            userRepository.save(user);
        }

        return user;
    }
}

3. Test

3.1 Controller 추가

@RestController
@RequiredArgsConstructor
public class RestApiController {

    private final UserRepository userRepository;
    private final JwtService jwtService;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final KakaoService kakaoService;
    private final NaverService naverService;

    /**
     * JWT 를 이용한 로그인
     */
    @GetMapping("/home")
    public String home() {
        return "home";
    }

    @PostMapping("/join")
    public String join(@ModelAttribute User user){

        user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));
        user.setRoles("USER");
        user.setCreateTime(LocalDateTime.now());
        userRepository.save(user);

        return "회원가입완료";
   }

   @GetMapping("/api/v1/user")
    public String test1() {
        return "success";
   }

    @GetMapping("/api/v1/manager")
    public String test2() {
        return "success";
    }
    @GetMapping("/api/v1/admin")
    public String test3() {
        return "success";
    }



    /**
     *   JWT를 이용한 네이버 로그인
     */

    /**front-end로 부터 받은 인가 코드 받기 및 사용자 정보 받기,회원가입 */
    @GetMapping("/api/oauth/token/kakao")
    public Map<String,String> KakaoLogin(@RequestParam("code") String code) {

        //access 토큰 받기
        KakaoToken oauthToken = kakaoService.getAccessToken(code);

        //사용자 정보받기 및 회원가입
        User saveUser = kakaoService.saveUser(oauthToken.getAccess_token());

        //jwt토큰 저장
        JwtToken jwtTokenDTO = jwtService.joinJwtToken(saveUser.getUserid());

        return jwtService.successLoginResponse(jwtTokenDTO);
    }
    //test로 직접 인가 코드 받기
    @GetMapping("/login/oauth2/code/kakao")
    public String KakaoCode(@RequestParam("code") String code) {
        return "카카오 로그인 인증완료, code: "  + code;
    }


    /**
     * JWT를 이용한 네이버 로그인
     */

    @GetMapping("/api/oauth/token/naver")
    public Map<String, String> NaverLogin(@RequestParam("code") String code) {

        NaverToken oauthToken = naverService.getAccessToken(code);

        User saveUser = naverService.saveUser(oauthToken.getAccess_token());

        JwtToken jwtToken = jwtService.joinJwtToken(saveUser.getUserid());

        return jwtService.successLoginResponse(jwtToken);
    }
    @GetMapping("/login/oauth2/code/naver")
    public String NaverCode(@RequestParam("code") String code) {
        return "네이버 로그인 인증완료, code: "  + code;
    }




    /**
     * refresh token 재발급
     * @return
     */
    @GetMapping("/refresh/{userId}")
    public Map<String,String> refreshToken(@PathVariable("userId") String userid, @RequestHeader("refreshToken") String refreshToken,
                                           HttpServletResponse response) throws JsonProcessingException {

        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");

        JwtToken jwtToken = jwtService.validRefreshToken(userid, refreshToken);
        Map<String, String> jsonResponse = jwtService.recreateTokenResponse(jwtToken);

        return jsonResponse;
    }

}

Kakao, Naver로 부터 code를 받는 부분은 프론트엔드가 처리해야 되는 로직이다. 간단히 React도 구현하여 처리하려고 했지만 연습이니만큼 code도 같이 서버에서 받도록 구성하였다.

3.2 test.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<a href="https://kauth.kakao.com/oauth/authorize?client_id=xxx&redirect_uri=http://localhost:8880/login/oauth2/code/kakao&response_type=code"> 카카오 인가코드 발습</a>

<a href="https://nid.naver.com/oauth2.0/authorize?client_id=xxx&redirect_uri=http://localhost:8880/login/oauth2/code/naver&response_type=code"> 네이버 인가코드 발습</a>
</body>
</html>

3.3 Response 보기

(1) /api/oauth/token/kako uri로 code와 함께 요청

(2) 응답(Response)

PostMan
temp
controller에서 해당 uri를 처리하는 메소드에서 전달받은 code를 사용하여 access 토큰, 사용자 정보를 요청하여 받게 되고 처음이용자에 대해서 강제회원가입을 시켜준다.

MySQL
temp

앞서 일반 로그인 테스트한 access 토큰 기한만료에 대한 처리, refresh 토큰 재발급에 대해서도 동일하게 작동하게 된다.


😂마치며...
처음 JWT토큰에 대해서 공부하면서 새로운 지식들을 알게 되서 뿌듯했다.
더불어 이 글을 읽게 된다면 아래 질문에 대해서 답변해주시며 감사하겠습니다.

💡질문!
: 소셜 로그인을 통해서 로그인한 사용자에 대해서 DB의 사용자의 password필드는 어떤 값으로 설정하는것이 맞습니까?!

profile
백엔드 개발자를 꿈꾸며 기록중💻

0개의 댓글