SY 개발일지
article thumbnail

나의 경험을 정리해 둔 자료로써, 정확히 100% 모든 로직이 완벽하다고는 하지 못하지만, react-native에서 스프링 시큐리티와 oauth2를 이용하여 소셜로그인을 구현하였다는 데에 의의를 둔다.

 

프로젝트를 진행하며 리액트 네이티브와 스프링 시큐리티를 이용하여 소셜로그인을 구현해야 했다. 자료를 확인하며 리액트 네이티브의 경우 소셜로그인을 지원하지 않았다. (코틀린정도만 정식으로 지원하는 듯?)

그래서 어떤 분이 만드신 라이브러리를 이용하여 구현해볼까 하다가 그냥 처음부터 구현해보기로 하였다.

 

❗ 여기서 참고할 점은 어떤 분들은 프론트엔드에서 카카오 REST API를 통해 code를 받아온 후, 그 code를 백으로 넘겨 데이터를 받는 식으로 한 분들도 많았다. 근데 나는 spring security oauth에 내장되어 있는 기능을 이용하고 싶었고, 그래야 보안 이 강하면서 다른 소셜 로그인을 구현할 때에 확장성 이 크다고 생각하여 프론트에서는 웹만 열어주고 백엔드에서 모든 처리를 진행하였다.

 

전체적인 프로세스는 다음과 같다.

카카오 디벨롭퍼 설정

1. 카카오 디벨롭퍼에 어플리케이션을 등록

 

이렇게 어플리케이션에 추가하면 된다 !

 

2. 사이트 도메인 등록

[ 내 애플리케이션 > 앱 설정 > 플랫폼 ]

 

나같은 경우에는 프론트 서버와 백엔드 서버를 모두 등록해주었다.

추후 사이트 도메인으로 수정할 예정이다.

 

3. 카카오 로그인 기능 활성화

[ 내 애플리케이션 > 제품 설정 > 카카오 로그인 ]

 

 

4. 리다이렉트 URI 설정

[ 내 애플리케이션 > 제품 설정 > 카카오 로그인 ]

여기에 백엔드서버/login/oauth2/code/kakao 를 입력해준다. 스프링 시큐리티에서는 기본적으로

/login/oauth2/code/kakao 라는 redirect URI를 제공한다.

 

5. 동의항목 설정

[ 내 애플리케이션 > 제품 설정 > 카카오 로그인 > 동의 항목 ]

필요한 동의 항목을 체크해준다. 나는 이메일을 받도록 하였다.

 

 

이렇게 하면 우선 카카오 디벨롭퍼에서 설정해주어야 할 것들은 모두 완료되었다.

 

스프링 시큐리티 설정

1. application-oauth.yml 작성

스프링 시큐리티 내 application-oauth.yml을 이용하여 다음과 같이 입력한다.

스프링 시큐리티에서는 기본적으로 oauth로그인을 위한 기능을 제공하는데, google, github, facebook과 같은 경우에는 이미 provider가 지정되어 있지만, 네이버나 카카오와 같은 경우에는 provider가 없기 때문에 직접 작성해주어야 한다.

spring:
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: [CLIENT_ID]
            client-secret: [CLIENT_SECRET]
            redirect-uri: http://[IP]/login/oauth2/code/kakao # spring security에서는 기본적으로 /login/oauth2/code/{registrationId} 로 구성
            client-name: Kakao
            client-authentication-method: client_secret_post # 다른 서비스와 달리 카카오는 필수 파라미터 값들을 담아 POST로만 요청 가능
            authorization-grant-type: authorization_code
            scope: profile_nickname, profile_image, account_email
        provider:
          kakao:
            authorization_uri: <https://kauth.kakao.com/oauth/authorize>
            token_uri: <https://kauth.kakao.com/oauth/token> # 토큰 얻는 uri
            user-info-uri: <https://kapi.kakao.com/v2/user/me> # 토큰을 이용해 사용자의 정보를 가져오는 uri
            user-info-authentication-method: header
            user_name_attribute: id

CLIENT_ID: [ 내 애플리케이션 > 앱 설정 > 앱 키 > REST API 키 ]

CLIENT_SECRET: [ 내 애플리케이션 > 제품 설정 > 카카오 로그인 > 보안 > Client Secret 코드 ]

redirect-uri: 백 서버

scope: 내가 원하는 정보를 , 를 이용하여 나열

 

application-oauth.yml을 사용하기 위해 application.yml에도 등록해준다.

spring:
  profiles:
    include: oauth # application-oauth.yml도 설정

 

Spring

SpringConfig.java

스프링 시큐리티를 이용하여 oauth로그인을 해주기 위해서는 SecurityConfig에 다음과 같은 코드를 추가해주어야 한다.

@EnableWebSecurity
@EnableMethodSecurity
@Configuration
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
   
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
               ...
                 // 스프링 oauth2 로그인.
                .oauth2Login((oauth2) -> {
                    oauth2
                        .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint  // oauth2 로그인 성공 이후 사용자 정보를 가져올 때 설정 담당
                                .userService(customOAuth2UserService));
                        oauth2.successHandler(oAuth2AuthenticationSuccessHandler);
                }) 
        ;

        return http.build();
    }
}

 

.auth2Login을 이용하여 auth2Login시 해당 로직이 실행되도록 한다.

.userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(customOAuth2UserService));

 

여기에서 유저의 정보를 처리한 후, 그 결과를 Authentication에 담아

oauth2.successHandler(oAuth2AuthenticationSuccessHandler);

에서 설정한 handler에서 나머지 처리를 한다.

 

즉, customOAuth2UserService 에서 OAuthAttribute 에 데이터를 담아 oAuth2AuthenticationSuccessHandler 에서 처리한다고 생각하면 쉽다. 이 로직을 하나씩 설명해보도록 하겠다.

 

OAuthAttribute.java

우선, OAuth2 데이터 객체가 들어갈 Dto를 작성해준다. 이 OAuthAttributes에서 REST API를 통해 받은 데이터를 내가 필요한 형태로 만들어준다.

@Getter
public class OAuthAttributes {
    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private String uid;
    private String email;
    private String nickname;
    private String profileImage;
    private LoginMethod method;

    @Builder
    public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String uid, String email, String nickname, String profileImage, LoginMethod method) {
        this.attributes = attributes;
        this.nameAttributeKey=nameAttributeKey;
        this.uid = uid;
        this.email = email;
        this.nickname = nickname;
        this.profileImage = profileImage;
        this.method = method;
    }

    public static OAuthAttributes of(String registrationId, String nameAttributeKey, Map<String, Object> attributes) {
        // 카카오 로그인이라면
//        if (registrationId.equals("KAKAO"))
//            return ofKakao(nameAttributeKey, attributes);
        return ofKakao(nameAttributeKey, attributes);
    }

    private static OAuthAttributes ofKakao(String nameAttributeKey, Map<String, Object> attributes) {
				Map<String, Object> kakao_account = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
        return OAuthAttributes.builder()
                .attributes(attributes)
                .nameAttributeKey(nameAttributeKey)
                .uid("KAKAO".concat(String.valueOf(attributes.get("id"))))
                .email((String) kakao_account.get("email"))
                .profileImage((String) properties.get("profile_image"))
                .nickname((String) properties.get("nickname"))
                .method(LoginMethod.KAKAO)
                .build();
    }

		// User Entity
    public Buyer toEntity() {
        return Buyer.builder()
                .uid(uid)
                .nickname(nickname)
                .email(email)
                .profileImage(profileImage)
                .status(UserStatus.ACTIVE)
                .method(LoginMethod.KAKAO)
                .build();
    }

    public Map<String, Object> toMap() {
        Map<String, Object> map = new HashMap<>();
        map.put("attributes", this.getAttributes());
        map.put("nameAttributeKey", this.getNameAttributeKey());
        map.put("uid", this.getUid());
        map.put("email", this.getEmail());
        map.put("profileImage", this.getProfileImage());
        map.put("nickname", this.getNickname());
        map.put("method", this.getMethod());
        return map;
    }

}

여기에서 데이터를 잘 파싱해야 한다.

 

카카오의 경우에는 위와같은 동의항목을 선택했을 때, 다음과 같은 응답을 보내준다.

{
	id=2957683217, 
	connected_at=2023-08-10T07:26:21Z, 
	properties={
		nickname=김소연, 
		profile_image=http://k.kakaocdn.net/dn/1G9kp/btsAot8liOn/8CWudi3uy07rvFNUkk3ER0/img_640x640.jpg, 
		thumbnail_image=http://k.kakaocdn.net/dn/1G9kp/btsAot8liOn/8CWudi3uy07rvFNUkk3ER0/img_110x110.jpg
	}, 
	kakao_account={
		profile_nickname_needs_agreement=false, 
		profile_image_needs_agreement=false, 
		profile={
			nickname=김소연, 
			thumbnail_image_url=http://k.kakaocdn.net/dn/1G9kp/btsAot8liOn/8CWudi3uy07rvFNUkk3ER0/img_110x110.jpg, 
			profile_image_url=http://k.kakaocdn.net/dn/1G9kp/btsAot8liOn/8CWudi3uy07rvFNUkk3ER0/img_640x640.jpg, 
			is_default_image=true
		},
		has_email=true, 
		email_needs_agreement=false, 
		is_email_valid=true, 
		is_email_verified=true, 
		email=la28s5d@gmail.com
	}
}

따라서 나는 properties와 kakao_account에 담긴 데이터를 Map형식으로 불러와 사용해주었다.

그렇다면 이러한 데이터를 처리해주는 CustomOAuth2UserService의 코드에 대해 설명해보도록 하겠다.

 

CustomOAuth2UserService.java

@Service
@Slf4j
@RequiredArgsConstructor
public class CustomOAuth2UserService  implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final BuyerRepository buyerRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        // 기본 OAuth2UserService 객체 생성한다
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();

        // OAuth2UserService를 사용하여 OAuth2User 정보를 가져온다
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        // 클라이언트 등록 ID(kakao)
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        log.info(registrationId+" 로그인 시도");
        
        // OAuth2 로그인 진행 시 키가 되는 필드 값(PK)
        // 예를 들어 구글의 경우에는 sub이다
        // 근데 우리 어플리케이션의 경우 uid가 있기 때문에 이걸로 판별해도 괜찮을 듯
        String userNameAttributeName = "uid";
        // OAuth2UserService를 사용하여 가져온  OAuth2User정보를 OAuth2Attribute 객체를 만든다
        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        // 저장
        Buyer buyer = save(attributes);

        // 여기서 리턴해주는 값을 successHandler에서 authentication 객체에서 확인할 수 있음.
        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(buyer.getDecriminatorValue()))
                , attributes.toMap()
                , attributes.getNameAttributeKey());
    }

    private Buyer save(OAuthAttributes attributes) {
        Buyer buyer = buyerRepository.findByUidAndMethod(attributes.getUid(), attributes.getMethod())
                // 우리 프로젝트에서는 유저의 닉네임/사진에 대한 실시간 정보가 필요 없기 때문에 update는 하지 않는다.
                .orElse(attributes.toEntity());
        return buyerRepository.save(buyer);
    }
}

CustomOAuth2UserService는 OAuth2UserService를 구현하였다. 각 코드에 대한 설명은 주석으로 달아놓았다.

이러한 과정을 통해 DefaultOAuth2User객체를 생성하는데, 이 결과는 다음 successHandler에서 Authentication을 통해 접근할 수 있다.

 

OAuth2AuthenticationSuccessHandler.java

@Component
@Slf4j
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final BuyerService buyerService;
    private final TokenProvider tokenProvider;
    private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        log.info("social login success");
        String accessToken = tokenProvider.createOAuthAccessToken(authentication);
        String uri = "<http://localhost:3000/success?token=>"+accessToken;
        redirectStrategy.sendRedirect(request, response, uri);
    }

}

여기서 생성한 객체는 authentication에 담겨져 있어 이를 이용하여 만료기간이 짧은(나의 경우에는 약 30초) accessToken을 하나 생성해주었다. 이 accessToken을 react-native에 전달하기 위해 uri에 쿼리스트링으로 보내주었다.

 

+ TokenProvider내 createAccessToken 메서드 참고

//Authentication 권한 정보 담은 토큰 생성
public String createOAuthAccessToken(Authentication authentication) {
    String authorities = authentication.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(","));

    long now = (new Date()).getTime();
    com.hihi.square.domain.user.entity.User user = userRepository.findByUID(authentication.getName())
            .orElseThrow(() -> new UserNotFoundException(
                    "User Not Found"));

    // AccessToken 생성
    String accessToken = Jwts.builder()
            .setSubject(authentication.getName())
            .claim(AUTHORITIES_KEY, authorities)
            .claim("uid", user.getUid())
            .claim("userId", user.getUsrId())
            .setExpiration(new Date(now + OAUTH_ACCESS_TOKEN_EXPIRE_TIME))
            .signWith(key, SignatureAlgorithm.HS256)
            .compact();

    return accessToken;
}

AccessToken의 경우 이전에 작성해둔 accessToken 발급 메서드의 만료 시간을 짧게 하여 생성해주었다.

백엔드에서 보낸 accessToken을 react native에서는 어떻게 받을까?

React Native

import React, {useEffect, useRef} from 'react';
import {Dimensions, Text, View} from 'react-native';
import axios from 'axios';
import {WebView, WebViewMessageEvent} from 'react-native-webview';

const KakaoLogin = ({navigation}: any) => {
  const webViewRef = useRef<WebView>(null);
  const handleNavigationStateChange = async (navState: any) => {
    const {url} = navState;
    const pattern = /\\/success\\?token=(.+)/;
    const match = url.match(pattern);
    if (match) {
      const token = match[1];
      await axios
        .get(`http://[백엔드 서버]/buyer/login?token=${token}`, {
          headers: {
            Authorization: `Bearer ${token}`,
          },
        })
        .then(response => {
          console.log(response.data.data);
          navigation.navigate('Login');
        });
    } else {
    }
  };

  return (
    <WebView
      style={{
        width: Dimensions.get('window').width,
        height: Dimensions.get('window').height,
      }}
      //웹뷰에서 보여줄주소
      source={{
        uri: '[백엔드 서버]/oauth2/authorization/kakao',
        method: 'GET',
        headers: {
          'Accept-Language': 'ko-KR,ko',
        },
      }}
      javaScriptEnabled
      ref={webViewRef}
      onNavigationStateChange={handleNavigationStateChange}
    />
  );
};

export default KakaoLogin;

react native에서 소셜 로그인 폼을 띄워주기 위해서는 WebView를 사용해주어야 한다. 여기서 주의할 점은 uri 가 /oauth2/authorization/{registrationId} 로 끝나야 한다는 것이다. 이 값은 스프링 시큐리티에서 기본적으로 설정한 URI이다.

여기서 사용자가 로그인을 하게 되면 스프링 시큐리티를 통해 데이터를 받아오게 된다. 그런 후, successHandler에서 설정한 String uri = "<http://localhost:3000/success?token=>"+accessToken; 이라는 uri가 프론트엔드 서버로 오게 된다. 이러한 경로는 WebView에서 설정한 onNavigationStateChange={handleNavigationStateChange} 에서 감지하게 된다.

const handleNavigationStateChange = async (navState: any) => {
  const {url} = navState;
  const pattern = /\\/success\\?token=(.+)/;
  const match = url.match(pattern);
  if (match) {
    const token = match[1];
    await axios
      .get(`[백엔드 서버]/buyer/login`, {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      })
      .then(response => {
        console.log(response.data.data);
        navigation.navigate('Login');
      });
  } else {
  }
};

navState에 담겨져 있는 url에서 token을 추출한다. 그 토큰을 이용하여 다시 스프링 서버에 axios 요청을 보내 데이터를 받아온다.

그러면 해당 토큰을 이용하여 스프링 시큐리티에서 토큰이 유효한지, 사용자가 유효한지 모두 체크해준 후, 최초 로그인 시 필요한 데이터를 data에 담아 보내주게 된다. 그렇게 데이터를 받게 되면 원하는 처리를 해주면 된다.

(예를 들면 accessToken을 recoil에 저장한다거나, 사용자 기본 정보를 저장해둔다거나, 다른 view를 보여준다거나..)

 

 

 

나는 여기까지만 담당하고 나머지 작업은 프론트엔드 담당자가 작성하기로 했다.

이렇게까지 하면 정상적으로 백엔드에서 보내준 데이터가 프론트엔드의 콘솔에 찍히게 된다 !

 

profile

SY 개발일지

@SY 키키

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!