ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring - Google Login API 연동 (No Library)
    IT, 프로그래밍/Spring 2020. 9. 14. 22:43

    2년전 제가 쓴 글인 스프링 Google Login 연동 포스팅이 그동안 많은 관심을 받았습니다.

     

    검색해보니 구글 검색에 상단에도 노출이 되더군요.

     

    댓글로도 많은 의견을 주셨는데, 그 중 라이브러리가 제대로 동작하지 않는다는 의견이 많았습니다.

     

    그래서 이번에는 라이브러리 없이 오직 구글에서 제공하는 OAuth API를 통해 구글 로그인, 사용자 정보를 가져오는 기능을 구현해 보겠습니다.

     

    만약 OAuth의 개념이 아직 익숙하지 않으신 분들은 제가 작성한 OAuth 포스팅을 먼저 보시면 많은 도움이 되실겁니다.

     

     

    OAuth 프로토콜의 이해와 활용 1 - 필요성과 역사

    WWW(World Wide Web)가 세상에 나온 지도 거의 30년이 되었고, 그동안 세상은 눈부시게 발전했습니다. 전화선을 꽂아 쓰던 PC통신의 시대를 넘어 초고속 인터넷이 보급되며 사람들은 누구나 쉽고 빠르�

    gdtbgl93.tistory.com

     

    OAuth 프로토콜의 이해와 활용 2 - OAuth란 무엇인가?

    앞에서는 OAuth가 왜 필요하고 어떻게 발전해 왔는지 알아보았습니다. 이번시간에는 OAuth가 무엇이고, 어떻게 흘러가는지 알아보겠습니다. OAuth는 위와 같은 플로우로 이루어 집니다.. 라고 하면 �

    gdtbgl93.tistory.com

     

    OAuth 프로토콜의 이해와 활용 3 - OAuth 인증방식의 종류

    이번 시간에는 OAuth에서 권한 인증을 승인할 수 있는 방식을 알아봅시다. 크게 4가지 방식이 있는데요. RFC 6749(OAuth 2.0 Framework)에서 소개된 방식입니다. Resource Owner에게 사용 허락을 받았다는 증서

    gdtbgl93.tistory.com

     

     

    먼저 사용자 인증 정보를 만드는 부분까지는 동일합니다.

     

    가장 먼저 할 것은 Google API Console로 가서 계정을 만드시고 왼쪽 메뉴 버튼을 클릭하셔서 사용자 인증 정보로 이동합니다.

     

    사용자 인증 정보 만들기에서 OAuth 클라이언트 ID를 선택합니다.

     

    애플리케이션 유형을 선택하는 타입이 나오는데 웹 애플리케이션으로 선택하면 됩니다.

     

     

    여기서 앱 이름과 인증 코드를 Redirect 받을 URL(승인된 리디렉션 URI)을 추가합니다.

     

    프로젝트로 들어가셔서 클라이언트 ID와 비밀 코드를 따로 보관해 둡시다.

    client secret(클라이언트 보안비밀)은 유출이 안되도록 조심하세요.

     

     

    * 본 포스팅에 사용하는 API 문서는 아래를 참고하세요.

     

     

    Using OAuth 2.0 for Web Server Applications  |  Google ID 플랫폼

    This document explains how web server applications use Google API Client Libraries or Google OAuth 2.0 endpoints to implement OAuth 2.0 authorization to access Google APIs. OAuth 2.0 allows users to share specific data with an application while keeping the

    developers.google.com

     

     

    먼저 클라이언트 페이지에서 구글 인증 서버로 인증 코드 발급 요청을 보낼 URL에 링크를 겁니다.

     

    요청을 받는 엔드포인트는 https://accounts.google.com/o/oauth2/v2/auth 이며 아래 파라미터들을 쿼리스트링으로 만들어, GET 요청으로 전달해야 합니다.

     

    필수값 외에 더 많은 파라미터들이 있는데 여기서 확인해 보시구요. 

     

    또 중요한 값이 access_type값 인데 offline으로 넘겨야 refresh token이 발급이 됩니다.

     

    access_type이 offline이라는건 유저가 지금 브라우저에 머무르지 않는 상태여도 토큰을 발행받을 수 있는것을 말합니다.

     

    예를 들어 백엔드 서버에서 DB에 저장된 유저의 refresh token을 통해 토큰을 재발급 받는다거나.. 혹은 배치를 통해 한번에 유저의 토큰을 갱신한다던가 하는 작업이 가능하게 되는것이죠.

     

    더 궁금하신분들은 아래의 링크를 참고하세요. 상세하게 나와있습니다. 영어라는 점만 빼면요

     

     

     

    What does "offline" access in OAuth mean?

    What exactly does the word "offline" mean with regard to the offline access granted by an OAuth server? Does it mean that the resource server will return data about the user even when the user is ...

    stackoverflow.com

     

    Using OAuth 2.0 for Web Server Applications  |  Google ID 플랫폼

    This document explains how web server applications use Google API Client Libraries or Google OAuth 2.0 endpoints to implement OAuth 2.0 authorization to access Google APIs. OAuth 2.0 allows users to share specific data with an application while keeping the

    developers.google.com

     

     

     

    <!DOCTYPE html>
    <html>
    <head>
    <meta charset="UTF-8">
    <title>HELLO OAuth</title>
    
    </head>
    
    <body>
    	<fieldset>
    		<label>로그인</label> <br>
    		<div id="googleLoginBtn" style="cursor: pointer">
    			<img id="googleLoginImg" src="./images/btn_google_signin_light_pressed_web.png">
    		</div>
    	</fieldset>
    </body>
    
    <script>
     	const onClickGoogleLogin = (e) => {
        	//구글 인증 서버로 인증코드 발급 요청
     		window.location.replace("https://accounts.google.com/o/oauth2/v2/auth?
            client_id=yourClientID
            &redirect_uri=http://localhost:8080/login/google/auth
            &response_type=code
            &scope=email%20profile%20openid
            &access_type=offline")
     	}
    	
    	const googleLoginBtn = document.getElementById("googleLoginBtn");
    	googleLoginBtn.addEventListener("click", onClickGoogleLogin);
        
    </script>
    </html>

     

     

    서버측에 요청 및 응답을 받기 위한 모델을 만들어 줍니다.

     

    pom.xml에 롬복에 대한 의존성을 추가해줍니다.

    		<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
    		<dependency>
    			<groupId>org.projectlombok</groupId>
    			<artifactId>lombok</artifactId>
    			<version>1.18.12</version>
    			<scope>provided</scope>
    		</dependency>
    

     

    요청을 받을 모델입니다.

    package com.example.demo.model;
    
    import lombok.Builder;
    import lombok.Data;
    
    @Data
    @Builder
    public class GoogleOAuthRequest {
    	private String redirectUri;
    	private String clientId;
    	private String clientSecret;
    	private String code;
    	private String responseType;
    	private String scope;
    	private String accessType;
    	private String grantType;
    	private String state;
    	private String includeGrantedScopes;
    	private String loginHint;
    	private String prompt;
    }
    

     

    응답을 받을 모델입니다.

     

     

    package com.example.demo.model;
    
    import lombok.Data;
    
    
    @Data
    public class GoogleOAuthResponse {
    	
    	private String accessToken;
    	private String expiresIn;
    	private String refreshToken;
    	private String scope;
    	private String tokenType;
    	private String idToken;
    	
    	
    };

     

     

    이제 인증 코드를 전달받는 부분을 확인해 봅시다.

    아까 클라이언트 페이지에서 구글 인증서버로 인증코드 발행을 요청하였습니다.

     

    그러면 사용자는 아래와 같은 화면으로 리다이렉션이 되는데요.

     

    평소에 서비스 가입하실때 자주 보신 화면이죠?

    여기서 하단에 있는 이 서비스에서 사용할 권한에 대해 나와있구요.

     

     

     

    이 화면은 OAuth 동의 화면 => 앱 수정으로 가시면 확인하실 수 있습니다.

    사용할 Scope는 여기서 추가를 하실 수 있구요.

    기본적으로 email, profile, openid가 추가되어 있습니다.

     

    범위 추가 버튼을 클릭하시면 다른 서비스에 대한 접근 권한을 추가하실 수 있습니다. (구글 드라이브라던가..)

     

    하지만 인증 코드를 발급받을 때 넘기는 scope 파라미터에 지정을 해두지 않으면,

    지정되지 않은 값은 넘어오지 않으니 참고하세요. 

     

    사용자가 아이디를 선택해 로그인하면 인증 코드가 발급이 됩니다.

    인증 코드는 302 리다이렉션을 통해 인증 서버를 호출한 클라이언트를 거쳐 다시 서버로 전달되는데요,

    이때 아까 인증코드를 요청할때 함께 넘겼던 redirect url로 요청됩니다.

     

     

     

    /**
    	 * Authentication Code를 전달 받는 엔드포인트
    	 **/
    	@GetMapping("google/auth")
    	public String googleAuth(Model model, @RequestParam(value = "code") String authCode)
    			throws JsonProcessingException {
    		
    		//HTTP Request를 위한 RestTemplate
    		RestTemplate restTemplate = new RestTemplate();
    
    		//Google OAuth Access Token 요청을 위한 파라미터 세팅
    		GoogleOAuthRequest googleOAuthRequestParam = GoogleOAuthRequest
    				.builder()
    				.clientId(clientId)
    				.clientSecret(clientSecret)
    				.code(authCode)
    				.redirectUri("http://localhost:8080/login/google/auth")
    				.grantType("authorization_code").build();
    
    		
    		//JSON 파싱을 위한 기본값 세팅
    		//요청시 파라미터는 스네이크 케이스로 세팅되므로 Object mapper에 미리 설정해준다.
    		ObjectMapper mapper = new ObjectMapper();
    		mapper.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE);
    		mapper.setSerializationInclusion(Include.NON_NULL);
    
    		//AccessToken 발급 요청
    		ResponseEntity<String> resultEntity = restTemplate.postForEntity(GOOGLE_TOKEN_BASE_URL, googleOAuthRequestParam, String.class);
    
    		//Token Request
    		GoogleOAuthResponse result = mapper.readValue(resultEntity.getBody(), new TypeReference<GoogleOAuthResponse>() {
    		});
    
    		//ID Token만 추출 (사용자의 정보는 jwt로 인코딩 되어있다)
    		String jwtToken = result.getIdToken();
    		String requestUrl = UriComponentsBuilder.fromHttpUrl("https://oauth2.googleapis.com/tokeninfo")
    		.queryParam("id_token", jwtToken).encode().toUriString();
    		
    		String resultJson = restTemplate.getForObject(requestUrl, String.class);
    		
    		Map<String,String> userInfo = mapper.readValue(resultJson, new TypeReference<Map<String, String>>(){});
    		model.addAllAttributes(userInfo);
    		model.addAttribute("token", result.getAccessToken());
    
    
    
    		return "/google.html";
    
    	}
    

     

     

     

     

     

    인증 코드를 전달 받은 엔드포인트에서는 인증 코드와 함께 구글 인증서버로 토큰 발행을 요청하면 되는데요,

     

    전달할 파라미터는 위와 같습니다.

    요청은 POST고, redirect_uri는 인증코드 발행시 사용했던 url을 적어주면 됩니다.

     

    요청이 성공하면 아래와 같이 토큰이 응답 메시지로 넘어오게 됩니다.

     

    토큰 정보와 함께 id_token 이라는 키에 jwt 형식으로 인코딩된 유저 정보가 함께 넘어옵니다.

     

    만약 여기서 다른 서비스에 접근해서 데이터를 들고오는게 필요하다~ 하시는 분들은 전달 받은 액세스토큰을 요청 헤더의 Authentication 필드에 넣어서 보내시면 됩니다.

     

    OAuth의 핵심은 유저의 정보를 가져오는게 아닌, 다른 서비스에 접근할 수 있는 권한을 부여 받는 것이라는걸 잊지 마시구요.

     

    마지막으로 토큰을 만료시키는 방법에 대해 알아보곘습니다.

     

    토큰을 즉시 만료시켜야 하는 경우, https://oauth2.googleapis.com/revoke 에 쿼리스트링으로 token=xxx 형식으로 토큰을 붙여 전달하면 됩니다 

     

     

     

    	/**
    	 * 토큰 무효화 
    	 **/
    	@GetMapping("google/revoke/token")
    	public Map<String, String> revokeToken(@RequestParam(value = "token") String token) throws JsonProcessingException {
    
    		Map<String, String> result = new HashMap<>();
    		RestTemplate restTemplate = new RestTemplate();
    		final String requestUrl = UriComponentsBuilder.fromHttpUrl(GOOGLE_REVOKE_TOKEN_BASE_URL)
    				.queryParam("token", token).encode().toUriString();
    		
    		String resultJson = restTemplate.postForObject(requestUrl, null, String.class);
    		result.put("result", "success");
    		result.put("resultJson", resultJson);
    
    		return result;
    
    	}

     

     

    위의 예제를 잘 숙지하시면 다른 서비스의 OAuth 연동도 쉽게 하실 수 있습니다.

    다 비슷비슷 하거든요.

     

    혹시 더 궁금하신점이나 잘못된 곳 있으면 언제든지 댓글로 알려주세요~!

     

     

    전체 코드는 아래를 참고하시고 깃허브에도 올려두었으니 참고하시기 바랍니다.

    github.com/curiduck/spring-google-oauth

     

    curiduck/spring-google-oauth

    Contribute to curiduck/spring-google-oauth development by creating an account on GitHub.

    github.com

     

     

    더보기

     

    package com.example.demo.controller;
    
    import java.util.HashMap;
    import java.util.Map;
    
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.http.ResponseEntity;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    import org.springframework.web.client.RestTemplate;
    import org.springframework.web.util.UriComponentsBuilder;
    
    import com.example.demo.model.GoogleOAuthRequest;
    import com.example.demo.model.GoogleOAuthResponse;
    import com.fasterxml.jackson.annotation.JsonInclude.Include;
    import com.fasterxml.jackson.core.JsonProcessingException;
    import com.fasterxml.jackson.core.type.TypeReference;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.fasterxml.jackson.databind.PropertyNamingStrategy;
    
    @Controller
    @RequestMapping("login")
    public class LoginController {
    
    	final static String GOOGLE_AUTH_BASE_URL = "https://accounts.google.com/o/oauth2/v2/auth";
    	final static String GOOGLE_TOKEN_BASE_URL = "https://oauth2.googleapis.com/token";
    	final static String GOOGLE_REVOKE_TOKEN_BASE_URL = "https://oauth2.googleapis.com/revoke";
    
    	@Value("${api.client_id}")
    	String clientId;
    	@Value("${api.client_secret}")
    	String clientSecret;
    
    
    	/**
    	 * Authentication Code를 전달 받는 엔드포인트
    	 **/
    	@GetMapping("google/auth")
    	public String googleAuth(Model model, @RequestParam(value = "code") String authCode)
    			throws JsonProcessingException {
    		
    		//HTTP Request를 위한 RestTemplate
    		RestTemplate restTemplate = new RestTemplate();
    
    		//Google OAuth Access Token 요청을 위한 파라미터 세팅
    		GoogleOAuthRequest googleOAuthRequestParam = GoogleOAuthRequest
    				.builder()
    				.clientId(clientId)
    				.clientSecret(clientSecret)
    				.code(authCode)
    				.redirectUri("http://localhost:8080/login/google/auth")
    				.grantType("authorization_code").build();
    
    		
    		//JSON 파싱을 위한 기본값 세팅
    		//요청시 파라미터는 스네이크 케이스로 세팅되므로 Object mapper에 미리 설정해준다.
    		ObjectMapper mapper = new ObjectMapper();
    		mapper.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE);
    		mapper.setSerializationInclusion(Include.NON_NULL);
    
    		//AccessToken 발급 요청
    		ResponseEntity<String> resultEntity = restTemplate.postForEntity(GOOGLE_TOKEN_BASE_URL, googleOAuthRequestParam, String.class);
    
    		//Token Request
    		GoogleOAuthResponse result = mapper.readValue(resultEntity.getBody(), new TypeReference<GoogleOAuthResponse>() {
    		});
    
    		//ID Token만 추출 (사용자의 정보는 jwt로 인코딩 되어있다)
    		String jwtToken = result.getIdToken();
    		String requestUrl = UriComponentsBuilder.fromHttpUrl("https://oauth2.googleapis.com/tokeninfo")
    		.queryParam("id_token", jwtToken).encode().toUriString();
    		
    		String resultJson = restTemplate.getForObject(requestUrl, String.class);
    		
    		Map<String,String> userInfo = mapper.readValue(resultJson, new TypeReference<Map<String, String>>(){});
    		model.addAllAttributes(userInfo);
    		model.addAttribute("token", result.getAccessToken());
    
    
    
    		return "/google.html";
    
    	}
    
    	/**
    	 * 토큰 무효화 
    	 **/
    	@GetMapping("google/revoke/token")
    	public Map<String, String> revokeToken(@RequestParam(value = "token") String token) throws JsonProcessingException {
    
    		Map<String, String> result = new HashMap<>();
    		RestTemplate restTemplate = new RestTemplate();
    		final String requestUrl = UriComponentsBuilder.fromHttpUrl(GOOGLE_REVOKE_TOKEN_BASE_URL)
    				.queryParam("token", token).encode().toUriString();
    		
    		String resultJson = restTemplate.postForObject(requestUrl, null, String.class);
    		result.put("result", "success");
    		result.put("resultJson", resultJson);
    
    		return result;
    
    	}
    	
    	
    	
    	
    
    }

     

Designed by Tistory.