[RN 프로젝트] #8 리액트네이티브 + 스프링부트(Spring Boot) + JWT 카카오 소셜 로그인 구현
my code archive
article thumbnail
반응형

아무리 여러 번을 해봐도 로그인은 늘 어렵다.....ㅎㅎㅎ

그리고 아직 친해지지 못한 맥북 + 리액트 네이티브 환경에서 하려니 엄청난 에러를 겪고 겨우 성공한 카카오 로그인...

시작!!

 

⭐️Frontend

0. 카카오 개발자센터 애플리케이션

설명 생략

 

1. 리액트네이티브 카카오 로그인 라이브러리 설치

npm install @react-native-seoul/kakao-login
//RN 0.60.X 이상부터는 Auto linking 지원
pod install

https://github.com/crossplatformkorea/react-native-kakao-login

 

GitHub - crossplatformkorea/react-native-kakao-login: react-native native module for Kakao sign in.

react-native native module for Kakao sign in. Contribute to crossplatformkorea/react-native-kakao-login development by creating an account on GitHub.

github.com

 

2. info.plist 작성

 <key>CFBundleURLTypes</key>
 <array>
+ <dict>
+   <key>CFBundleTypeRole</key>
+   <string>Editor</string>
+   <key>CFBundleURLSchemes</key>
+   <array>
+     <string>kakao{카카오 네이티브앱 아이디를 적어주세요}</string>
+   </array>
+ </dict>
 </array>
 <key>CFBundleVersion</key>
 <string>1</string>
+ <key>KAKAO_APP_KEY</key>
+ <string>{카카오 네이티브앱 아이디를 적어주세요}</string>
+ <key>KAKAO_APP_SCHEME</key> // 선택 사항 멀티 플랫폼 앱 구현 시에만 추가하면 됩니다
+ <string>{카카오 앱 스킴을 적어주세요}</string> // 선택 사항 
+ <key>LSApplicationQueriesSchemes</key>
+ <array>
+   <string>kakaokompassauth</string>
+   <string>storykompassauth</string>
+   <string>kakaolink</string>
+ </array>

네이티브 앱키는 카카오 개발자 센터에서 발급받은 저 네이티브 앱 키를 적으면 된다!

중요한 건 카카오, 네이버 이렇게 여러 가지의 소셜 로그인을 구현할 때는 아래처럼 <array> 태그 안에 같이 적어줘야 한다는 것!

그리고 네이티브 앱키를 적을 때 앞에 kakao를 꼭 붙여줘야 한다는 것! 예를 들어 1234라면 kakao1234 이렇게!

 

3. Swift Bridging Header 추가

 

4. XCode TARGETS > Info > URL Types 

마찬가지로 네이티브 앱키 넣어주기.

 

5. AppDelegate.m 수정

위치는 여기!

 

아래 코드 추가하기

#import <RNKakaoLogins.h>
- (BOOL)application:(UIApplication *)app
     openURL:(NSURL *)url
     options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
   if([RNKakaoLogins isKakaoTalkLoginUrl:url]) {
      return [RNKakaoLogins handleOpenUrl: url];
   }

 return NO;
}

 

6.  실행 코드

버튼 만들고

<Button opt={"kakao"} text="카카오톡 아이디 로그인" handlePress={signInWithKakao}/>

라이브러리 import하고 아래와 같이 작성하면 됨!

import { login, logout, unlink, getProfile, getAccessToken} from '@react-native-seoul/kakao-login';

const signInWithKakao = async () => {
	const token = await login();
    const kakaoProfileResult = await getProfile();
}

 

💡 실행 화면

 

⭐️Backend

1. build.gradle 의존성

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly("com.mysql:mysql-connector-j")
	annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	
	implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
	implementation 'javax.xml.bind:jaxb-api:2.3.0'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'
	implementation 'com.google.code.gson:gson:2.8.7'
	implementation 'com.googlecode.json-simple:json-simple:1.1.1'
	implementation 'com.auth0:java-jwt:4.4.0'
	
	annotationProcessor 'org.projectlombok:lombok'
	
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}

 

2. application.yml , application-ouath.xml 작성

server:
  port: 8085
  servlet:
    context-path: /
    encoding:
      charset: UTF-8
      enabled: true
      force: true
      
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/스키마명?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8
    username: DB ID
    password: DB PW


  jpa:
    database: mysql
    open-in-view: true
    hibernate:
      ddl-auto: update
      naming:
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
    show-sql: true
    properties:
        hibernate:
          format_sql: true
          show_sql: true
          
  h2:
   console:
     enabled: true
    
  profiles:
    include: oauth


logging:
  level:
    org:
      hibernate:
        type:
          descriptor:
            sql: trace
spring:
  security:
    oauth2:
      client:
        registration:
          naver:
            client-id: 네이버 client id
            client-secret: 네이버 client secret
            client-name: Naver
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8085/ouath/callback/naver
            
          kakao:
            client-id: 카카오 client id
            client-secret: 카카오 client secret
            redirect-uri: http://localhost:8085/ouath/callback/kakao
            authorization-grant-type: authorization_code
            client-authentication-method: POST
            client-name: Kakao
            scope:
            - profile_nickname
            - profile_image
            
        provider:
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id
jwt:
  secretKey: 시크릿키
  token-validity-in-seconds: 86400
  tokenExpiry: 1800000

 

3. Entity, dto 작성

 

4. UserRepository

 

5. JWT 관련 코드 Config 파일 (JwtProvider.java , JwtRequestFilter.java)

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtProvider {
	
	private static final String AUTHORITIES_KEY = "auth";
	private static final String BEARER_TYPE = "bearer";
	private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;
	
	private UserService userService;
	
	@Value("${jwt.secretKey}")
    private String secretKey;
	
    /*public JwtProvider(@Value("${jwt.secret}") String secretKey) {
		byte[] keyBytes = Decoders.BASE64.decode(secretKey);
		this.key = Keys.hmacShaKeyFor(keyBytes);
	} */
	
	@PostConstruct
	protected void init() {
		secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
	}
    
    //JW 토큰 생성
    public String createToken(String userPk, String roles) {
    	
    	Claims claims = Jwts.claims().setSubject(userPk);
    	claims.put("roles", roles);
    	Date now = new Date();
    	
    	return Jwts.builder()
    			.setClaims(claims)
    			.setIssuedAt(now)
    			.setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRE_TIME))
    			.signWith(SignatureAlgorithm.HS256, secretKey)
    			.compact();
    }
    
    public Authentication getAuthentication(String accessToken) {

    	UserEntity userDetails = userService.loadUserByUsername(this.getUserPk(accessToken));
    	
    	return new UsernamePasswordAuthenticationToken(userDetails.getEmail(),"",userDetails.getAuthorities());
    }
    
    public String getUserPk(String token) {
    	
    	return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }
    
 // Request의 Header에서 token 값을 가져옵니다. "X-AUTH-TOKEN" : "TOKEN값'
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("token");
    }
    
    public boolean validateToken(String token) {
        try {
            //Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
        	Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }
    
    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}
@RequiredArgsConstructor
@Component
public class JwtRequestFilter extends OncePerRequestFilter {
	
	public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";
    private final JwtProvider jwtProvider;
	
	 private String resolveToken(HttpServletRequest request) {
	        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
	        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
	            return bearerToken.substring(7);
	        }
	        return null;
	    }

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
		
        String jwt = resolveToken(request);

        if (StringUtils.hasText(jwt) && jwtProvider.validateToken(jwt)) {
            Authentication authentication = jwtProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
		
	}

}

 

6. 스프링 시큐리티 관련 Config 파일 (SecurityConfig.java)

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
	
	public static final String FRONT_URL = "http://localhost:3000";
	
	private final JwtProvider jwtProvider;
	
	private final UserOAuthService userOAuthService;
	
	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		
		
			http.csrf().disable()
					.cors()
					.and()
					.httpBasic().disable()
					.formLogin().disable()
					.authorizeHttpRequests(request -> request
							.requestMatchers(FRONT_URL+"/**").authenticated().anyRequest().permitAll())
					.sessionManagement()	//session을 사용하지 않음
					.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
					.and()										
					.exceptionHandling()
					.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
					.and()
					.addFilterBefore(new JwtRequestFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
					
					
	        return http.build();
	        
	}

}

 

7.  카카오 로그인 프론트엔드 리액트네이티브 코드

⭐️카카오 로그인 전체 흐름

(Front) 로그인 -> 인가코드 발급 -> 토큰 발급(access, refresh) -> access Token을 백엔드로 넘김
-> (Backend) 사용자 인증 후 DB 저장 -> JWT 토큰 발급 -> JWT localStorage에 저장
import React, {useEffect, useState} from "react";
import { StyleSheet, View, SafeAreaView, Image, TouchableOpacity, Alert} from "react-native";
import AsyncStorage from '@react-native-async-storage/async-storage';
import axios from "axios";
import {
    widthPercentageToDP as wp,
    heightPercentageToDP as hp,
    widthPercentageToDP,
    heightPercentageToDP,
  } from 'react-native-responsive-screen';
//import {useDispatch} from 'react-redux';
import { login, logout, unlink, getProfile, getAccessToken} from '@react-native-seoul/kakao-login';
import { useNavigation } from "@react-navigation/native";
import { defaultFontText as Text } from "../../components/Text";
import Button from "../../components/Button";
import { kakaoRedirectURL } from "../../utils/OAuth";

const LoginScreen = () => {

    const navigation = useNavigation();
    const [loading, setLoading] = React.useState(false);
    const [errortext, setErrortext] = useState('');
    const [kakaoToken, setKakaoToken] = useState('');

    const signInWithKakao = async () => {
        try {
            const token = await login();
            const kakaoProfileResult = await getProfile();
            const accessToken = await getAccessToken();
            setKakaoToken(JSON.stringify(token));
            console.log(kakaoProfileResult);
            return fetch(`${kakaoRedirectURL}`, {
                method: 'POST',
                body: JSON.stringify({kakaoProfileResult, accessToken}),
                headers: {
                    'Content-Type': 'application/json',
                },
            })
                .then(response => response.json())
                .then(responseJson => {
                    setLoading(false);
                    console.log(JSON.stringify(responseJson));
                    if(responseJson.status === 200){
                        AsyncStorage.setItem('user_email', responseJson.email);
                        AsyncStorage.setItem('user_token', responseJson.token);
                        AsyncStorage.setItem('user_name', responseJson.username);
                        navigation.navigate("Main");
                    }else {
                        setErrortext(responseJson.message);
                        console.log('이메일 혹은 패스워드를 확인해주세요.');
                    }
                })
        }catch(err) {
            console.error('login err', err);
        }
    };

    const signOutWithKakao = async() => {
        try {
            const message = await logout();
            console.log(message);
            setKakaoToken('');
        }catch (err) {
            console.log('signOut error', err);
        }
    };
    
    const getKakaoProfile = async () => {
        try {
            const profile = await getKakaoProfile();
            setKakaoToken(JSON.stringify(profile));
        } catch(err) {
            console.error('signOut err', err);
        }
    };

    return (
        <SafeAreaView style={styles.container}>
            <View style={styles.topArea}>
                <View style={styles.textArea}>
                    <Image source={require("../../assets/logo/muggle_logo.png")} style={styles.logo}/>
                    <Text style={styles.text}>머글들은 모르는 뮤덕들의 세계</Text>
                </View>
            </View>
            <View style={styles.btnArea}>
                <Button opt={"apple"} text="Apple 아이디 로그인" />
                <Button opt={"kakao"} text="카카오톡 아이디 로그인" handlePress={signInWithKakao}/>
                <Button opt={"naver"} text="네이버 아이디 로그인"  handlePress={() => {signInNaver(naverKey)}}/>
            </View>
        </SafeAreaView>
    )
}

const styles = StyleSheet.create({
    /* 스타일 생략 */
});

export default LoginScreen;

 

8. 백엔드 UserController

위의 리액트 코드에서 fetch로 통신을 보낸 kakaoRedirectURL과 일치하는 주소를 컨트롤러단에 PostMapping으로 매핑시켜준다.

JSON으로 받아오기 때문에 JSONObject로 받아서 파싱하여 처리.

STS에서 디버깅을 걸어보면 아래와 같이 토큰이 넘어온다! 사용자 정보도 잘 넘어옴! 

9. DB 저장까지 완료!

반응형
profile

my code archive

@얼레벌레 개발자👩‍💻

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

반응형