TIL

2023.12.08 TIL - REST API 서버 네이버,카카오 소셜 로그인 구현

소셜 로그인 구현

구현 요구사항 중 네이버, 카카오 소셜 로그인 구현이 있어

Spring 에서 제공하는 oauth2-client 모듈을 이용하여 구현했다.

 

환경 

SpringBoot 3.1.5

Java 17

Gradle 8.4

 

준비사항 

build.gradle에서 해당 의존성 추가

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

인증 과정

소셜 로그인 인증 과정은 다음과 같습니다.

 

1, 2. 사용자가 서버에서 설정된 oauth2 login url을 통해 로그인 요청시 (ex. /api/auth/login/{provider})

    서버에서 로그인페이지로 리다이렉트시킵니다.

 

3.  로그인을 완료하게되면 인증서버에서 받은 code 값을 서버에 전달합니다.

 

4. 사용자로 부터 받은 code 값으로 인증서버에서 사용자 정보를 요청합니다.

 

5, 6. 받은 사용자 정보로 DB에서 회원을 확인하고 회원정보를 저장 또는 업데이트를 합니다.

 

7. 사용자 정보를 기반으로 토큰 또는 세션/쿠키를 생성하여 사용자에게 반환합니다.


구현

구현단계는 크게 3가지입니다.

1.  인증서버에 어플리케이션 등록
2. 개발 환경 설정
3. 초기화 및 로그인 기능 구현하기

 

인증서버 어플리케이션 등록

구현할 로그인은 네이버, 카카오 이므로 각 개발자 센터에서 애플리케이션을 등록해야합니다.

신청방법은 아래 링크를 참고하시면 됩니다.

 

https://notspoon.tistory.com/41

 

네이버 로그인 쉽게 구현하기 1편 - Naver Developers 설정

네이버 로그인 API 클라이언트 입장에서 수많은 사이트의 모든 아이디 비밀번호를 기억하기는 쉽지 않다. 또한 서비스를 제공해주는 리소스 오너 또한 안전하게 보관하여야 하기 때문에 부담된

notspoon.tistory.com

 

https://notspoon.tistory.com/34

 

카카오 로그인 쉽게 구현하기 1편 - Kakao Developers 설정

카카오 로그인 API 클라이언트 입장에서 수많은 사이트의 모든 아이디 비밀번호를 기억하기는 쉽지 않다. 또한 서비스를 제공해주는 리소스 오너 또한 안전하게 보관하여야 하기 때문에 부담된

notspoon.tistory.com


개발 환경설정

application.properties

1 에서 등록한 후 받은 API 키와 Redirect Url 을 application.properties에 설정해주어야합니다.

#Socal Login
# - Naver
# registration
spring.security.oauth2.client.registration.naver.client-id=${naverClientId} //클라이언트 ID
spring.security.oauth2.client.registration.naver.client-secret=${naverClientSecret} //클라이언트 Secret
//redirect url -> 어플리케이션 등록 때 설정한 redirect url
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId} 
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email.profile_image //정보제공 항목
spring.security.oauth2.client.registration.naver.client-name=Naver

# provider
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response

# - Kakao
# registration
spring.security.oauth2.client.registration.kakao.client-id=${kakaoClientId} //어플리케이션 API Key
spring.security.oauth2.client.registration.kakao.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.kakao.scope=profile_nickname,account_email,profile_nickname
spring.security.oauth2.client.registration.kakao.client-name=Kakao

# provider
spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
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.provider.kakao.user-name-attribute=id

 

securityConfig.java

스프링 시큐리티 설정에 oauth2-client 설정을 해야합니다.

각 Csutom 코드들은 뒤에서 설명하겠습니다.

http
    .oauth2Login(oauth2 -> oauth2
    	//사용자가 소셜로그인을 하기위한 요청 base url (ex. 네이버로그인 : /api/auth/login/naver)
        //해당 url로 요청시 application.properties에서 설정한 key, redirect-url을 가지고 로그인페이지로 리다이렉트합니다.
        .authorizationEndpoint(endpointConfig -> endpointConfig.baseUri("/api/auth/login"))
        //사용자가 로그인페이지에 로그인 후 전달한 code 값을 통해 인증서버에 사용자 정보를 요청하고 사용자 정보를 처리하는 custom service를 주입합니다.
        .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(oAuth2UserService))
        //인증처리가 성공하면 해당 handler에서 후처리(jwt 생성 후 response에 세팅)를 합니다.
        .successHandler(oauth2SuccessHandler)
        //인증처리가 실패한 경우 해당 custom handler에서 후처리 (에러응답 세팅을 합니다.
        .failureHandler(oauth2AuthenticationFailureHandler)
    );

로그인 구현

User.java

소셜로그인 테이블을 따로 생성하지 않고 컬럼으로 각 소셜 id 식별자 컬럼을  추가했습니다.

//User Entity 구현 코드...

@Setter
@Column(name = "kakao_id")
private String kakaoId;

@Setter
@Column(name = "naver_id")
private String naverId;

 

UserRepository.java

인증서버로부터 받은 사용자의 id 값으로 회원DB에 회원정보를 찾기위한 쿼리메소드를 추가했습니다.

//UserRepository 구현코드..

Optional<User> findByNaverId(String naverId);

Optional<User> findByKakaoId(String kakaoId);

 

OAuth2UserDetails.java

인증서버로부터 사용자 정보를 받게되면 attributes 라는 객체에 보내지게 됩니다. 

인증서버별로 key 값이 다르기 때문에 각각 해당 클래스를 상속받아 구현해주어야합니다.

ex. 네이버 : response, 카카오: properties

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

   protected OAuth2UserDetails(Map<String, Object> attributes) {
      this.attributes = attributes;
   }

   public Map<String, Object> getAttributes() {
      return attributes;
   }
   
 //회원정보를 저장할 때 필요한 필드들 추가
   public abstract String getId();

   public abstract String getName();

 

NaverOAuth2UserDetails.java

public class NaverOAuth2UserDetails extends OAuth2UserDetails {

   public NaverOAuth2UserDetails(Map<String, Object> attributes) {
      super((Map<String, Object>)attributes.get("response"));
   }

   @Override
   public String getId() {
      return String.valueOf(attributes.get("id"));
   }

   @Override
   public String getName() {
      return String.valueOf(attributes.get("name"));
   }
}

 

KakaoOAuth2UserDetails.java

public class KakaoOAuth2UserDetails extends OAuth2UserDetails {

   private final Map<String, Object> properties;

   public KakaoOAuth2UserDetails(Map<String, Object> attributes) {
      super(attributes);
      properties = (Map<String, Object>)attributes.get("properties");
   }

   @Override
   public String getId() {
      return String.valueOf(attributes.get("id"));
   }

   @Override
   public String getName() {
      return String.valueOf(properties.get("nickname"));
   }

   @Override
   public String getEmail() {
      return String.valueOf(properties.get("account_email"));
   }

   @Override
   public String getImageUrl() {
      return null;
   }
}

 

 

OAuth2UserInfoFactory.java

인증서버별 UserDetails를 생성하기 위한 Factory 클래스입니다.

public class OAuth2UserInfoFactory {
	private OAuth2UserInfoFactory() {
	}

	public static OAuth2UserDetails getOAuth2UserInfo(SocialType socialType, Map<String, Object> attributes) {
		return switch (socialType) {
			case NAVER -> new NaverOAuth2UserDetails(attributes);
			case KAKAO -> new KakaoOAuth2UserDetails(attributes);
		};
	}
}

 

CustomOAuth2UserService.java

소셜로그인 인증처리를 위한 클래스입니다.

@Slf4j(topic = "oauth2 인증 서비스")
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

   private final UserRepository userRepository;

   public CustomOAuth2UserService(UserRepository userRepository) {
      this.userRepository = userRepository;
   }

   @Override
   public OAuth2User loadUser(OAuth2UserRequest oauth2Userrequest) throws
      OAuth2AuthenticationException {
      OAuth2User oauth2User = super.loadUser(oauth2Userrequest);

      try {
         return processOAuth2User(oauth2Userrequest, oauth2User);
      } catch (Exception exception) {
         log.error("OAuth2 Authentication process error");
         throw new InternalAuthenticationServiceException(exception.getMessage(), exception.getCause());
      }
   }

	//인증처리로직
   private OAuth2User processOAuth2User(
      OAuth2UserRequest oauth2Userrequest, OAuth2User oauth2User) {
      //인증서버 제공회사명 (Naver, Kakao) -> application.properties에 name으로 설정되어있음
      String registrationId = oauth2Userrequest.getClientRegistration().getRegistrationId().toUpperCase();
      SocialType socialType = valueOf(registrationId);
      
	  //Factory에서 각 인증서버별 UserDetails 생성
      OAuth2UserDetails oAuth2UserDetails = OAuth2UserInfoFactory.getOAuth2UserInfo(socialType,
         oauth2User.getAttributes());
      return new CustomUserDetails(findUser(socialType, oAuth2UserDetails), oauth2User.getAttributes());
   }

   private User findUser(SocialType socialType, OAuth2UserDetails oauth2Userdetails) {
      Optional<User> userOptional = switch (socialType) {
         case NAVER -> userRepository.findByNaverId(oauth2Userdetails.getId());
         case KAKAO -> userRepository.findByKakaoId(oauth2Userdetails.getId());
      };

      return userOptional.orElseGet(() -> createUser(socialType, oauth2Userdetails));
   }

   //DB에 회원 정보가 없는경우 생성 후 저장 
   private User createUser(SocialType socialType, OAuth2UserDetails oauth2Userdetails) {
      String username = switch (socialType) {
         case KAKAO -> socialType.getCode() + oauth2Userdetails.getId();
         case NAVER -> socialType.getCode() + oauth2Userdetails.getId().substring(0, 10);
      };

      User user = new User(
         username,
         "NO_PASSWORD",
         UserRoleEnum.USER
      );

      switch (socialType) {
         case KAKAO -> user.setKakaoId(oauth2Userdetails.getId());
         case NAVER -> user.setNaverId((oauth2Userdetails.getId()));
      }
      return userRepository.save(user);
   }

 

 

OAuth2AuthenticationSuccessHandler.java

위에 CustomOAuth2UserService 에서 인증처리가 모두 완료되는 경우 실행됩니다.

@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
   private final JwtProvider jwtProvider;
   private final RefreshTokenRepository refreshTokenRepository;
   private final ObjectMapper objectMapper;

   @Override
   public void onAuthenticationSuccess(
      HttpServletRequest request, HttpServletResponse response, Authentication authentication)
      throws IOException, ServletException {

      CustomUserDetails userDetails = (CustomUserDetails)authentication.getPrincipal();
      User user = userDetails.getUser();
      TokenDto tokenDto = jwtProvider.createToken(user.getUsername(), user.getRole());

      // DB 저장
      RefreshToken refreshToken = RefreshToken.of(user.getUsername(), tokenDto.getRefreshToken());
      refreshTokenRepository.save(refreshToken);

      //response 저장
      jwtProvider.setTokenResponse(tokenDto, response);
      response.setContentType(MediaType.APPLICATION_JSON_VALUE);
      response.setCharacterEncoding(StandardCharsets.UTF_8.name());
      response.setStatus(ResponseCode.LOGIN.getHttpStatus());
      response.getWriter().write(
         objectMapper.writeValueAsString(
            BaseResponse.of(
               ResponseCode.LOGIN.getMessage(),
               ResponseCode.LOGIN.getHttpStatus(),
               ""
            )
         )
      );
   }

 

 

OAuth2AuthenticationFailuereHandler.java

위에 CustomOAuth2UserService 에서 인증처리가 실패할 경우 실행됩니다.

@Slf4j
@Component
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
   private final ObjectMapper objectMapper;

   public OAuth2AuthenticationFailureHandler(ObjectMapper objectMapper) {
      this.objectMapper = objectMapper;
   }

   @Override
   public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException exception)
      throws IOException {
      response.setContentType(MediaType.APPLICATION_JSON_VALUE);
      response.setCharacterEncoding(StandardCharsets.UTF_8.name());
      response.setStatus(INTERNAL_SERVER_ERROR.getHttpStatus());
      response.getWriter().write(
         objectMapper.writeValueAsString(
            BaseResponse.of(
               INTERNAL_SERVER_ERROR.getMessage(),
               INTERNAL_SERVER_ERROR.getHttpStatus(),
               ""
            )
         )
      );
   }
}

 


테스트

 http://localhost:8080/api/auth/login/naver 로 로그인 요청 <- Security Config에서 설정한 base url

 

로그인 후 인증 성공

 

RefreshToken, AccessToken 값 확인

 

 

로그인 후 인증 처리 실패

 


직접 RestTemplate을 사용하여 소셜로그인을 구현하지 않고 oauth2-client 모듈을 활용하여 

소셜로그인을 구현해보았습니다.

 

궁금하신 점을 댓글로 달아주시면 아는 선까지 답변드리겠습니다

감사합니다!

728x90