본문 바로가기
부트캠프 개발일기/Main-Project

102일차: Main-Project Day 9 (Google OAuth2.0 회원가입, CSR 방식)

by shyun00 2023. 7. 10.

뭔가 많이 배운것같아서 기분좋은 월요일이다.

지난주에 회원에 대한 전반적인 기능을 마무리하고 오늘은 지난번에 덮어두었던 OAuth2.0를 다시 적용해보기로 했다.

오늘은 아예 Google OAuth2.0 공식문서를 쭉 살펴보았다.

참고: 구글 OAuth2.0 웹서버 공식문서

 

웹 서버 애플리케이션용 OAuth 2.0 사용  |  Authorization  |  Google for Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Switch to English 의견 보내기 웹 서버 애플리케이션용 OAuth 2.0 사용 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분

developers.google.com

 

차근차근 내용을 살펴보니 지난번 수업때 배웠던 내용들과 내가 어려워했던 부분(리디렉션 URI)이 명확해졌다.

 

우선, OAuth2.0을 통한 인증 과정을 간단히 나타내면 아래 그림과 같다.

그림을 각 단계별로 살펴보면 다음과 같다.

일반적으로 OAuth2.0에서 클라이언트는 Authorization Server로 요청을 보내는 백엔드 서버를 의미하지만, 편의를 위해 그림에 적힌 명칭을 사용했다.

* 개인 학습자료입니다. 틀린 부분이 있다면 편하게 말씀해주세요!

< OAuth 적용  응답 흐름>

① 클라이언트(사용자)가 프론트엔드 서버로 소셜 로그인(혹은 회원가입)을 요청한다.

② 프론트서버에서 백엔드 서버로 소셜로그인을 하기 위한 링크를 요청한다.

    응답을 받을 프론트엔드 리디렉션 URI도 포함해준다. (아직 코드 작업이 마무리되지 않고 배포가 되지 않아 이 부분은 적용되지 않았다.)

③ 클라이언트가 실제로 로그인할 수 있는 링크를 전달한다.

    이 링크에는 클라이언트가 로그인에 성공했을때 Authorization Code를 받게되는 리디렉션 URI를 포함된다.

    여기에 적용되는 리디렉션 URI는 구글 OAuth 클라이언트 계정에서 리디렉션 URI로 추가되어있어야한다.

④ 전달받은 링크를 클라이언트에게 제공, 접속할 수 있도록 한다.

⑤ 클라이언트가 소셜 로그인 페이지에서 로그인한다.

⑥ 소셜 로그인에 성공하면 구글에서 Authorization Code를 ②에서 지정한 백엔드 리디렉션 URI로 보내준다.

⑦ 전달받은 Authorization Code를 사용해 Resource Server에 접근하기 위한 Access Token을 발급받는다.

⑧ Authorization Code를 검증해서 Access Token을 발급한다.

⑨ Authorization Code를 사용해 클라이언트 정보를 요청한다.

⑩ Access Token 검증에 성공하면 클라이언트 정보(유저 정보)를 전달한다.

⑪ 전달받은 클라이언트 정보를 사용해 회원가입, 로그인 등의 절차를 수행한다.

⑫ 처리된 결과를 프론트엔드 서버로 전달한다.

⑬ 클라이언트에게 최종 결과를 표시한다. -> 지금 12, 13번의 내용이 구현되지 않았다.

 

위 방식을 간단히 요약하면 프론트엔드 서버에서 백엔드 서버로 /oauth/signup 혹은 /oauth/login 으로 Get 요청을 보내면 백엔드 서버에서는 해당되는 리디렉션 URI를 제공, 사용자가 해당 URI에서 소셜로그인에 성공하면 Authorization Code가 다시 백엔드 서버로 전달되어 이후 비즈니스 로직이 수행되는 흐름이다.

 

OAuth2.0을 적용하는 방법을 구글링했을때 필터를 사용하는 경우도 있고 별도의 컨트롤러를 작성하는 경우도 있었다.

우선 이번에는 컨트롤러를 사용해 작성했는데, 시간이 되면 어떤 방식이 더 적합할지 한번 더 살펴볼 예정이다.

(기능의 범위나 특성을 고려해서 리팩토링도 많이 해야할것같다.)

 


위에서 설명한 방식대로 아래 흐름으로 코드를 작성하고 로그인을 해보니, 회원가입까지는 정상적으로 되나 백엔드에서 처리한 비즈니스 로직의 결과물을 클라이언트(사용자)가 직접적으로 받고있었다. (프론트 서버로 전달 X, 클라이언트에게 바로 전달)

 

1. 프론트엔드에서 소셜 로그인 요청을 하면 컨트롤러에서 리디렉션 URI를 전달해준다.

2. 프론트엔드에서 사용자에게 리디렉션 URI를 연결해주고, 응답이 오면 자동으로 설정해둔 리디렉션 URI로 Get 요청이 온다. (Pathparam으로 Authentication이 온다.)

3. Authentication을 사용해 액세스 토큰을 발급하는 URI(https://oauth2.googleapis.com/token)으로 요청을 보낸다.

4. 발급받은 액세스 토큰으로 리소스 URI(https://www.googleapis.com/oauth2/v2/userinfo)로 회원 정보를 요청한다.

5. 전달받은 응답을 바탕으로 로그인, 회원가입 등의 로직을 구현한다.

 

생각해보니 리디렉션 URI에서 요청했으니, 별도의 리디렉션 URI를 설정해주지 않으면 요청이 왔던곳으로 응답이 다시 가는것같다.

이 부분에 대해 생각해보니 프론트엔드서버에서 구글에 AuthorizationCode를 받고, 받은 AuthorizationCode를 백엔드쪽으로 전달해주면 백엔드에서 해당 코드를 가지고 Google서버에서 인증받고 정보를 가져오는게 나을것같다.

 

이렇게 되면 Authorization Code를 가지고 구글 서버에서 인증을 받을때 프론트에서 사용했던 google Oauth 클라이언트 아이디, 시크릿, 리디렉션 URI가 필요하다 (리디렉션 URI까지 있어야 Authorization Code 검증을 할 수 있다.)

 

백엔드 URI로 작동되는 것을 확인했으니 내일 프론트 담당자와 얘기해서 두번째 방법으로도 한번 구현해봐야겠다!

[작성한 코드]

1. 구글에서 유저 정보를 받아오기 위한 클래스

@Getter
public class GoogleUserInfo {

    private String id;
    private String name;
    private String email;

}

2. 구글에서 액세스 토큰 받아오기 위한 클래스

@Getter
public class AccessTokenResponse {
    @JsonProperty("access_token")
    private String accessToken;

    @JsonProperty("token_type")
    private String tokenType;

    @JsonProperty("expires_in")
    private int expiresIn;

}

3. OAuth 로그인 / 회원가입을 담당하는 OauthService 클래스

@Slf4j
@Service
public class OauthService {
    private final RestTemplate restTemplate = new RestTemplate();
    private final MemberRepository memberRepository;
    private final JwtTokenizer jwtTokenizer;

    public OauthService(MemberRepository memberRepository, JwtTokenizer jwtTokenizer) {
        this.memberRepository = memberRepository;
        this.jwtTokenizer = jwtTokenizer;
    }

    // 클라이언트 아이디, 시크릿, URI등 필요한 정보는 환경변수에서 가져옴
    @Value("${security.oauth2.google.client-id}")
    private String clientId;

    @Value("${security.oauth2.google.client-secret}")
    private String clientSecret;

    ... 
    
    // 회원가입 전체 로직
    public Member socialSignUp(String code) {
        // Authorization에서 액세스 토큰을 얻는다.
        String accessToken = getAccessToken(code, "signup");
        
        // 리턴받은 AccessToken을 사용해 회원 정보를 얻는다.
        GoogleUserInfo member = getUserResource(accessToken);

        // 회원 정보를 저장한다.
        if (memberRepository.findByLoginId(member.getEmail()) == null) {
        Member newMember = new Member();
        newMember.setLoginId(member.getEmail());
        newMember.setMemberName(member.getName());
        newMember.setMemberType(Member.MemberType.GOOGLE);
        newMember.setMemberRole(Member.MemberRole.CUSTOMER);

        return memberRepository.save(newMember);
        } else {
            throw new BusinessLogicException(ExceptionCode.MEMBER_EXISTS);
        }
    }

    // Authorization을 사용해 액세스 코드를 받아오는 코드.
    // 파라미터 feat는 로그인과 회원가입의 리디렉션 URI가 달라서 맞춰주기위해 추가해주었다.
    // 자기에게 맞게 리디렉션 URI를 설정해주면 된다.
    public String getAccessToken(String authorizationCode, String feat) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        String redirectUri = "http://localhost:8080/" + feat + "/oauth2/code/google";

        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("code", authorizationCode);
        body.add("client_id", clientId);
        body.add("client_secret", clientSecret);
        body.add("redirect_uri", redirectUri); // Google에서 설정한 리디렉션 URI와 일치해야 정상적으로 검증된다.
        body.add("grant_type", "authorization_code");

        HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(body, headers);

        // Authorization을 받아서 액세스 토큰을 제공하는 tokenUri로 요청을 보낸다.
        // 이 때 지정한 AccessTokenReaponse 클래스 형태로 응답을 돌려받는다.
        ResponseEntity<AccessTokenResponse> responseEntity = restTemplate.exchange(
                tokenUri,
                HttpMethod.POST,
                requestEntity,
                AccessTokenResponse.class
        );

        if (responseEntity.getStatusCode() == HttpStatus.OK) {
            AccessTokenResponse accessTokenResponse = (AccessTokenResponse) responseEntity.getBody();
            return accessTokenResponse.getAccessToken();
        } else {
            throw new RuntimeException("Failed to get access token");
        }
    }

    // 리소스 서버에서 회원 정보를 가져오는 메서드
    private GoogleUserInfo getUserResource(String accessToken) {

        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(accessToken);

        // AccessToken을 헤더에 넣어서 액세스 토큰을 제공하는 tokenUri로 요청을 보낸다.
        RequestEntity<?> requestEntity = RequestEntity.get(resourceUri)
                .headers(headers)
                .build();
        
        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<GoogleUserInfo> responseEntity = restTemplate.exchange(
                requestEntity,
                GoogleUserInfo.class
        );

        if (responseEntity.getStatusCode().is2xxSuccessful()) {
            return responseEntity.getBody();
        } else {
            throw new RuntimeException("Failed to fetch Google user info");
        }
    }

    // 존재하는 회원인지 확인하는 메서드
    public Member findVerifiedMember(String loginId) {
        Member member = memberRepository.findByLoginId(loginId);
        if (member == null) throw new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND);
        return member;
    }
}

4. 사용자가 소셜로그인 URI에서 정상 로그인했을때 결과가 전달되는 컨트롤러 

@RestController
@RequestMapping
@Slf4j
public class MemberController {

    ...
    
    // Google에서 지정한 리디렉션 URI로 자동으로 요청이 전달된다.
    // Authorization은 RequestParam으로 전달된다.
    
    @GetMapping("/signup/oauth2/code/google")
    public ResponseEntity googleSignUp(@RequestParam String code){
    
        Member member = oauthService.socialSignUp(code);
    
    return new ResponseEntity<>(HttpStatus.CREATED);
}

[결과확인]

소셜로그인 URI로 접속하면 아래와 같이 로그인 화면이 나온다. 정상적으로 로그인되면 두번째 사진과 같은 응답을 받는다.

(회원가입이 완료되면 회원 식별자 memberId와 회원 닉네임이 리턴되도록 해두었다.)

실제로 회원 정보도 세번째 그림과 같이 DB에 저장되어있는것을 확인할 수 있다.

 

 

참고자료

OAuth 2.0 개념과 동작원리

구글 소셜 로그인 배포/로컬에 적용하기

[React] 소셜로그인 - 구글2(인가코드 받기 참고)

[OAuth2.0] 스프링부트로 Google 로그인 구현해보기   -> 코드 관련 도움을 많이 받았다.

스택오버플로우질문(Is there a right way to build a URL?)

Local에서 Google API 테스트(feat. Google OAuth 2)

Google OAuth 로그인 구현

[Spring] 게시판 만들기 - oauth2.0으로 회원가입/로그인 구현하기

Spring Boot OAuth2.0 적용기 

[Spring Boot] OAuth2 소셜 로그인 가이드(구글, 페이스북, 네이버, 카카오) -> OAuth2.0 이해에 도움이 많이 됐다.

Spring Security와 OAuth2.0와 JWT의 콜라보

Oauth Login을 통해 추가정보 입력받아 회원가입하기(feat: React, Spring)