본문 바로가기
Spring

[Spring] 로그인 구현 3 - 카카오 로그인 구현

by Bhinney 2022. 12. 20.

메인 프로젝트를 하면서 REST API로 카카오 로그인을 구현해보았다.

해당 부분을 기억하기 위해 이 글을 작성하였다.

이 포스팅은 프로젝트 과정에서 흐름을 기억하기 위해 작성한 것으로, 기본 흐름의 틀정도라고 생각하면 좋을 듯 하다.

해당 포스팅의 코드는 아래의 깃헙 주소에서도 확인 가능.

❗️본 포스팅은 이전 포스팅에서 이어집니다❗️

 

GitHub - Bhinney/Study: ✨ 공부하면서 기록하는 공간 ✨

✨ 공부하면서 기록하는 공간 ✨. Contribute to Bhinney/Study development by creating an account on GitHub.

github.com


✨ 들어가기 전에, 메인 프로젝트에서 사용한 카카오 로그인의 흐름 

: 아래의 흐름을 바탕으로 프로젝트의 코드에 약간의 변형을 주어 구현할 예정

📚 자료 참조 : https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api


📎 구현 전에 준비

  • ⭐️ Kakao Developers에서 애플리케이션 설정하기 ⭐️
  • https://developers.kakao.com/
  • 위의 사이트로 들어가 애플리케이션을 생성해준다.
  • 요약 정보에서 앱 키를 확인할 수 있다.
  • 제품 설정 👉🏻 카카오 로그인  👉🏻 Redirect Uri 등록
  • 제품 설정 👉🏻 동의 항목  👉🏻 필요한 정보 동의 설정 (필자는 이메일과 이름을 가져옴)

 

 


1️⃣ application.yml 수정

  • security 설정 추가
spring:

  ...
    
  security:
    oauth2:
      client:
        registration:
          kakao:
            clientId: ${KAKAO_CLIENT_ID}
            clientAuthenticationMethod: post
            authorizationGrantType: authorization_code
            redirectUri: http://localhost:8080/login/oauth2/code/kakao
            scope:
              - profile_nickname
              - account_email
            clientName: Kakao
        provider:
          kakao:
            authorizationUri: https://kauth.kakao.com/oauth/authorize
            tokenUri: https://kauth.kakao.com/oauth/token
            userInfoUri: https://kapi.kakao.com/v2/user/me
            userNameAttribute: id

	...

2️⃣ build.gradle 추가

  • oauth2 관련 의존성 추가

 

...
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
...

3️⃣ KakaoToken 클래스 생성

  • Kakao 응답 정보를 받아오기 위한 클래스

 

@Getter
@ToString
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;
}

4️⃣ KakaoProfile 클래스 생성

  • Kakao 회원 정보를 받아올 클래스
@Data
public class KakaoProfile {

   public String id; /* 소셜 아이디로 들어갈 에정 */
   public String connected_at;
   public Properties properties;
   public KakaoAccount kakao_account;

   public class Properties {
      public String nickname;
   }

   @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;
         public Boolean is_default_image;
      }
   }
}

 

5️⃣ KakaoUserInfo, OAuth2UserInfo, OAuth2UserInfoFactory 클래스 생성

public abstract class OAuth2UserInfo {
   protected Map<String, Object> attributes;

   public OAuth2UserInfo(Map<String, Object> attributes) {
      this.attributes = attributes;
   }

   public Map<String, Object> getAttributes() {
      return attributes;
   }

   public abstract String getId();

   public abstract String getName();

   public abstract String getEmail();
}

 

public class KakaoUserInfo extends OAuth2UserInfo{

   public KakaoUserInfo(Map<String, Object> attributes) {
      super(attributes);
   }

   /* 소셜 아이디 */
   @Override
   public String getId() {
      return attributes.get("id").toString();
   }

   /* 회원 이름 */
   @Override
   public String getName() {
      Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");

      if (properties == null) {
         return null;
      }

      return (String) properties.get("nickname");
   }

   /* 회원 이메일 */
   @Override
   public String getEmail() {
      return (String) attributes.get("account_email");
   }
}

 

public class OAuth2UserInfoFactory {
   public static OAuth2UserInfo getOAuth2UserInfo(ProviderType providerType, Map<String, Object> attributes) {
      switch (providerType) {
         case KAKAO: return new KakaoUserInfo(attributes);
         default: throw new IllegalArgumentException("Invalid Provider Type.");
      }
   }
}

6️⃣ KakaoService 클래스 생성

  • Kakao 토큰 받기
  • Kakao 사용자 저장 및 정보 가져오기
  • 첫 이용자는 강제 회원가입
  • 소셜 사용자는 SOCIAL 권한 부여 -> 이후 단 한 번만 권한 수정 가능 (Client or Seller)

 

@Service
@RequiredArgsConstructor
public class KakaoService {

   private final MemberRepository memberRepository;

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

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

   @Value("${spring.security.oauth2.client.provider.kakao.tokenUri}")
   private String tokenUri;

   @Value("${spring.security.oauth2.client.provider.kakao.userInfoUri}")
   private String userInfoUri;

   /* code 로 kakao 의 엑세스 토큰 발급 받기 */
   public KakaoToken getAccessToken(String code) {

      /* RequestParam */
      MultiValueMap<String, String> param = new LinkedMultiValueMap<>();
      param.add("grant_type", "authorization_code");
      param.add("client_id", clientId);
      param.add("redirect_uri", redirectUri);
      param.add("code", code);

      /* 요청 */
      HttpHeaders headers = new HttpHeaders();
      headers.set("Content-type","application/x-www-form-urlencoded;charset=utf-8");
      HttpEntity<MultiValueMap<String,String>> requestEntity = new HttpEntity<>(param, headers);

      RestTemplate template = new RestTemplate();
      KakaoToken kakaoToken = template.postForObject(tokenUri, requestEntity, KakaoToken.class);

      return kakaoToken;
   }

   /* 받은 엑세스 토큰으로 카카오 회원 정보 가져오기 */
   public KakaoProfile findProfile(String token) {
      RestTemplate template = new RestTemplate();
      HttpHeaders headers = new HttpHeaders();
      headers.set("Authorization", "Bearer " + token);

      HttpEntity<MultiValueMap<String, String> > requestEntity = new HttpEntity<>(null, headers);
      KakaoProfile kakaoProfile = template.postForObject(userInfoUri, requestEntity, KakaoProfile.class);

      return kakaoProfile;
   }

   /* 카카오 로그인 */
   @Transactional
   public Member saveMember(String accessToken) {

      /* 사용자 정보 받아오기 */
      KakaoProfile profile = findProfile(accessToken);
      Member member = memberRepository.findMemberByEmail(profile.getKakao_account().getEmail());

      /* 첫 이용자 강제 회원가입 */
      if (member == null) {
         String name = profile.getKakao_account().getProfile().getNickname();
         if (name == null || name.equals("")) {
            name = "이름을 입력하세요";
         }

         member = new Member(
            name,
            profile.getKakao_account().getEmail(),
            profile.getId(),
            "010-0000-0000",
            "주소를 입력하세요."
         );

         member.setCreateMember(
            "카카오 로그인 사용자",
            "SOCIAL",
            List.of("SOCIAL"),
            ProviderType.KAKAO
         );

         memberRepository.save(member);
      }

      /* 만일 사용자가 로컬 사용자라면 예외를 던져야 함 */
      if (member.getProviderType() == ProviderType.LOCAL) {
         throw new RuntimeException("소셜 로그인 사용자가 아닙니다.");
      }

      return member;
   }
}

6️⃣ MemberService에 카카오 로그인 추가

  • UsernamePasswordAuthenticationToken 를 쓰지 않으므로 새로운 함수 추가
@Service
@Slf4j
@Transactional
@RequiredArgsConstructor
public class MemberService {
   private final MemberRepository memberRepository;
   private final PasswordEncoder passwordEncoder;
   private final CustomAuthorityUtils authorityUtils;
   private final AuthenticationManagerBuilder authenticationManagerBuilder;
   private final JwtProvider jwtProvider;
   private final RedisTemplate<String, Object> redisTemplate;
   
   ...

   /* 카카오 로그인 */
   public TokenDto kakaoLogin(LoginRequestDto requestDto) {

      TokenDto tokenDto = jwtProvider.generatedTokenDto(requestDto.getEmail());

      /* Refresh Token 저장 */
      redisTemplate.opsForValue()
         .set("RefreshToken:" + requestDto.getEmail(), tokenDto.getRefreshToken(),
            tokenDto.getRefreshTokenExpiresIn() - new Date().getTime(), TimeUnit.MICROSECONDS);

      return tokenDto;
   }
   
   ...
}

6️⃣ MemberDto, MemberMapper에 소셜 응답추가

  • 소셜 권한 응답을 위해 DTO와 Mapper에 소셜 응답 추가
public class MemberDto {

   ...

   @Getter
   @Builder
   @AllArgsConstructor
   public static class SocialResponseDto{
      private long memberId;
      private String email;
      private String name;
      private String phone;
      private String address;
      private String role;
      private String accessToken;
   }
}

 

@Mapper(componentModel = "spring")
public interface MemberMapper {

   ...


   default MemberDto.SocialResponseDto memberToSocialResponseDto(Member member, String token) {
      if (member == null && token == null) {
         return null;
      }

      MemberDto.SocialResponseDto response =
         MemberDto.SocialResponseDto.builder()
            .memberId(member.getMemberId())
            .email(member.getEmail())
            .name(member.getName())
            .phone(member.getPhone())
            .address(member.getAddress())
            .role(member.getRole())
            .accessToken(token)
            .build();

      return response;
   }
}

7️⃣ MemberController에 카카오 로그인 추가

  • 리다이렉트로 들어오는 주소로 GetMapping
  • 해당 코드로 KakaoService에서 토큰 받고, 정보 가져오기
  • 서버에서 로그인 하여 서버의 토큰 발급
@Slf4j
@RestController
@RequiredArgsConstructor
@Transactional
public class MemberController {
   private final MemberService memberService;
   private final MemberMapper mapper;
   private final KakaoService kakaoService;
   
   ...

   /* 카카오 로그인 */
   /* frontend 로 부터 받은 인가 코드 받기 및 사용자 정보 받기, 회원가입 */
   @GetMapping("/login/oauth2/code/kakao")
   public ResponseEntity kakaoLogin(@RequestParam("code") String code) {

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

      /* 사용자 정보받기 및 회원가입 */
      Member member = kakaoService.saveMember(oauthToken.getAccess_token());

      /* jwt 토큰 저장 */
      LoginRequestDto requestDto = new LoginRequestDto(member.getEmail(), member.getPassword());
      TokenDto tokenDto = memberService.kakaoLogin(requestDto);

      /* 엑세스 토큰 헤더에 담아주기 */
      HttpHeaders httpHeaders = setHeader(tokenDto.getAccessToken());

      /* 역할에 따라 응답 바디가 다르므로, 나누어 주었다.*/
      if(member.getRole().equals("SELLER")) {
         return new ResponseEntity<>(mapper.memberToSellerResponseDto(member), httpHeaders, HttpStatus.OK);
      } else if (member.getRole().equals("CLIENT")) {
         return new ResponseEntity<>(mapper.memberToClientResponseDto(member), httpHeaders, HttpStatus.OK);
      } else if (member.getRole().equals("SOCIAL")) {
         return new ResponseEntity<>(mapper.memberToSocialResponseDto(member, tokenDto.getAccessToken()),
            httpHeaders, HttpStatus.OK);
      }

      /* 로그인이 실패할 경우, 문제가 존재하는 것 */
      throw new RuntimeException("로그인에 실패하였습니다.");
   }

   /* 로그인 헤더 설정 */
   private HttpHeaders setHeader(String token) {
      HttpHeaders httpHeaders = new HttpHeaders();
      httpHeaders.set("Authorization", token);

      return httpHeaders;
   }
}

 

 

 

댓글