OAuth2를 이용한 구글 소셜 로그인 처리(2) - RN에서 처리

Jongwon·2023년 3월 12일
1

DMS

목록 보기
12/18
post-thumbnail

이전 게시글에서는 Spring Boot 서버에서 전반적인 처리를 진행하는 로직을 구현했었습니다. 하지만 React Native과 같이 Native 기반의 모바일 앱에서는 소셜 로그인을 API로 처리할 수 없습니다.


이번 글에서는 React Native에서 전체적인 처리를 한 이후, 구글로부터 받은 id_token을 React Native앱이 Spring Boot 서버에 보내주는 순간부터의 처리 과정을 작성하겠습니다.


소셜 로그인 로직

시나리오는 다음과 같습니다.

  1. React Native(클라이언트)에서 구글과의 인증을 진행한다.

  2. React Native가 구글로부터 받은 Authorization Code를 이용하여 Access Token과 ID Token을 받는다.

  3. 클라이언트에서 Spring Boot 서버로 ID Token을 전송합니다.

  4. Spring Boot 서버에서 구글 외부 API에 접근합니다. 구글로부터 사용자 정보를 받아옵니다.

    "https://oauth2.googleapis.com/tokeninfo?id_token={ID_TOKEN}"으로 토큰을 전송하면 아래의 정보를 받을 수 있습니다.

    {
      "iss": "https://accounts.google.com",
      "azp": "32555350559.apps.googleusercontent.com",
      "aud": "32555350559.apps.googleusercontent.com",
      "sub": "111260650121185072906",
      "hd": "google.com",
      "email": "user@example.com",
      "email_verified": "true",
      "at_hash": "_LLKKivfvfme9eoQ3WcMIg",
      "iat": "1650053185",
      "exp": "1650056785",
      "alg": "RS256",
      "kid": "f1338ca26835863f671403941738a7b49e740fc0",
      "typ": "JWT"
      ...
    }

    참고: https://developers.google.com/identity/openid-connect/openid-connect?hl=ko#an-id-tokens-payload

  5. 이메일을 이용하여 DB에서 회원이 존재하는지 확인하고, 없다면 DB에 저장하고, 있다면 Token을 클라이언트로 전송합니다.



JSON을 처리할 라이브러리

JSON타입은 Java에서 여러 라이브러리가 지원하고 있습니다. 저는 이중에서 구글에서 제공한 simple-json 라이브러리를 사용하겠습니다.

✅build.gradle

아래 라이브러리를 gradle에 추가합니다.

	// https://mvnrepository.com/artifact/com.googlecode.json-simple/json-simple
	implementation group: 'com.googlecode.json-simple', name: 'json-simple', version: '1.1.1'


RestTemplate

Response를 전송하는 방식에는 여러가지가 있는데, 그 중 Blocking 방식으로 Rest 타입의 응답을 지원하는 템플릿은 RestTemplate입니다.

참고: https://okimaru.tistory.com/m/229

Spring Bean에 등록하고, 이후 여러 상황에서 응답을 보내야할 때 의존성 주입을 하여 사용하도록 하겠습니다.

✅WebConfig

//추가
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }


Entity 생성

이전 글에서도 설명했던 OAuth2Attribute를 생성하겠습니다.

✅OAuth2Attribute

@Data
@Builder
public class OAuth2Attribute {

    private String provider;
    private String userId;
    private String username;
    private String email;
    private String picture;
    private String nickname;

    public static OAuth2Attribute of(String provider, String usernameAttributeName, Map<String, Object> attributes) {
        switch (provider) {
            case "google":
                return OAuth2Attribute.ofGoogle(provider, usernameAttributeName, attributes);
            default:
                throw new RuntimeException("소셜 로그인 접근 실패");
        }

    }

    private static OAuth2Attribute ofGoogle(String provider, String usernameAttributeName, Map<String, Object> attributes) {

        return OAuth2Attribute.builder()
                .provider(provider)
                .username(String.valueOf(attributes.get("name")))
                .email(String.valueOf(attributes.get("email")))
                .userId(String.valueOf(attributes.get(usernameAttributeName)).concat("google"))
                .build();
    }
}


Service 생성

Service 계층에서는 id token을 이용하여 회원정보를 찾고, 없다면 생성하여 DB에 저장하는 작업을 진행합니다.

✅OAuth2UserService

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import uos.capstone.dms.domain.auth.OAuth2Attribute;
import uos.capstone.dms.domain.auth.Provider;
import uos.capstone.dms.domain.user.Member;
import uos.capstone.dms.domain.user.Role;
import uos.capstone.dms.mapper.MemberMapper;
import uos.capstone.dms.repository.MemberRepository;

import java.util.HashMap;
import java.util.Map;

@Service
@RequiredArgsConstructor
public class OAuth2UserService {

    private final MemberRepository memberRepository;
    private final RestTemplate restTemplate;

@Transactional(readOnly = false)
    public Map<String, Object> findOrSaveMember(String id_token, String provider) throws ParseException, JsonProcessingException {
        OAuth2Attribute oAuth2Attribute;
        switch (provider) {
            case "google":
                oAuth2Attribute = getGoogleData(id_token);
                break;
            default:
                throw new RuntimeException("제공하지 않는 인증기관입니다.");
        }

        Integer httpStatus = HttpStatus.OK.value();

        Member member = memberRepository.findByEmail(oAuth2Attribute.getEmail())
                .orElseGet(() -> {
                    Member newMember = Member.builder()
                            .userId(oAuth2Attribute.getUserId())
                            .email(oAuth2Attribute.getEmail())
                            .social(true)
                            .provider(Provider.of(provider))
                            .username(oAuth2Attribute.getUsername())
                            .build();

                    newMember.updateRole(Role.ROLE_USER);
                    return memberRepository.save(newMember);
                });

        if(member.getAddressDetail() == null || member.getBirth() == null || member.getNickname() == null || member.getPhoneNo() == null || member.getStreet() == null || member.getZipcode() == null) {
            httpStatus = HttpStatus.CREATED.value();
        }

        if(!member.isSocial()) {
            httpStatus = HttpStatus.ACCEPTED.value();
            member.updateSocial(Provider.of(provider));
            memberRepository.save(member);
        }

        Map<String, Object> result = new HashMap<>();
        result.put("dto", MemberMapper.INSTANCE.memberToMemberDTO(member));
        result.put("status", httpStatus);

        return result;
    }

    private OAuth2Attribute getGoogleData(String id_token)  throws ParseException, JsonProcessingException {

        HttpHeaders headers = new HttpHeaders();
        HttpEntity<String> entity = new HttpEntity<>(headers);
        String googleApi = "https://oauth2.googleapis.com/tokeninfo";
        String targetUrl = UriComponentsBuilder.fromHttpUrl(googleApi).queryParam("id_token", id_token).build().toUriString();

        ResponseEntity<String> response = restTemplate.exchange(targetUrl, HttpMethod.GET, entity, String.class);

        JSONParser parser = new JSONParser();
        JSONObject jsonBody = (JSONObject) parser.parse(response.getBody());

        Map<String, Object> body = new ObjectMapper().readValue(jsonBody.toString(), Map.class);

        return OAuth2Attribute.of("google", "sub", body);
    }
}

  • 이후에 구글 이외에 다른 소셜 로그인도 구현할 예정이기 때문에 provider도 파라미터로 받아옵니다.

  • getGoogleData()
    "https://oauth2.googleapis.com/tokeninfo?id_token={id_token}" 로 request를 보내면 구글에서 토큰 유효성을 확인하고, 어플리케이션이 원하는 데이터를 JSON으로 보내줍니다. JAVA 자체에는 JSON을 처리할 수 있는 로직이 없어 JSON-SIMPLE 라이브러리를 사용하였습니다.

  • Map타입으로 반환하는 이유는, 기존 회원이 소셜 로그인 연동을 하는것인지 아니면 신규회원이 소셜로그인으로 가입하는지 HTTP Status로 구분하기 위해서입니다.
    - 200(OK) : existing member try to log-in
    - 201(CREATED) : some attributes are empty or new to app
    - 202(ACCEPTED) : existing member trying to link social log-in



Controller 생성

클라이언트가 구글로부터 id_token을 받은 뒤, 서버로 보낼 URL을 설정하고, 이를 처리해줄 Controller를 생성하겠습니다.

✅ApiController

//추가
    @Operation(summary = "구글 소셜 로그인")
    @GetMapping("/oauth2/google")
    public ResponseEntity<TokenResponseDTO> oauth2Google(@RequestParam("id_token") String idToken) throws ParseException, JsonProcessingException {
        Map<String, Object> memberMap =  oAuth2UserService.findOrSaveMember(idToken, "google");
        TokenDTO tokenDTO = tokenService.createToken((MemberDTO) memberMap.get("dto"));

        ResponseCookie responseCookie = ResponseCookie
                .from("refresh_token", tokenDTO.getRefreshToken())
                .httpOnly(true)
                .secure(true)
                .sameSite("None")
                .maxAge(tokenDTO.getDuration())
                .path("/")
                .build();

        TokenResponseDTO tokenResponseDTO = TokenResponseDTO.builder()
                .isNewMember(false)
                .accessToken(tokenDTO.getAccessToken())
                .build();

        return ResponseEntity.status((Integer) memberMap.get("status")).header("Set-Cookie", responseCookie.toString()).body(tokenResponseDTO);
    }

React Native 클라이언트로부터 "http://localhost:8080/api/oauth2/google"로 요청을 받으면 토큰을 전달하게 됩니다.

하지만, 현재 Spring Security의 Filter에 의해 막히기 때문에 SecurityConfig의 Permit Url을 수정합니다.

✅SecurityConfig

//수정
    private static final String[] URL_TO_PERMIT = {
            "/member/login",
            "/member/signup",
            "/v3/api-docs/**",
            "/swagger-ui/**",
            "/oauth2/**",
            "/api/**"          //추가
    };

profile
Backend Engineer

0개의 댓글