[Spring Boot] Google 소셜 로그인: 가장 쉽게 구현하기(HTML, JavaScript API) - 게시판 웹
개요
구글 소셜 로그인을 구현하는 가장 간단한 방법은 HTML, JavaScript API만을 사용해서 구글 로그인 버튼(Iframe)을 렌더링하고 인증하는 방법이다. 로그인 성공시 반환받는 Credential JWT 토큰을 복호화하여 사용자 정보에 접근할 수 있다.
사전 설정
이전 포스팅에서 GCP 사전 설정을 마친 후 Client id가 필요하다.
참조
migration 관련
https://developers.google.com/identity/oauth2/web/guides/migration-to-gis?hl=ko
https://developers.google.com/identity/gsi/web/guides/overview?hl=ko
이전에 사용하던 구글 로그인 API가 deprecated 되었다는 에러로그에서 그대로 따라갈 수 있는 docs 링크이다. 위 링크에서 추천하는 방법대로 진행했다.
구글 로그인 버튼 렌더링
참조: https://developers.google.com/identity/gsi/web/guides/personalized-button?hl=ko
Google 계정으로 로그인 버튼 UX | Authentication | Google for Developers
Chrome 서드 파티 쿠키 지원 중단이 2024년 1분기에 시작됩니다. 이전 가이드에 따라 잠재적인 변경사항을 검토하고 사용자가 웹사이트에 로그인할 때 부정적인 영향을 받지 않도록 하세요. 이 페
developers.google.com
위 링크를 참조하면 구글 웹 로그인 버튼과 로그인 과정에 대해 전반적으로 이해할 수 있다.
HTML 렌더링
<html>
<body>
<script src="https://accounts.google.com/gsi/client" async></script>
<div id="g_id_onload"
data-client_id="YOUR_GOOGLE_CLIENT_ID"
data-login_uri="https://your.domain/your_login_endpoint"
data-auto_prompt="false">
</div>
<div class="g_id_signin"
data-type="standard"
data-size="large"
data-theme="outline"
data-text="sign_in_with"
data-shape="rectangular"
data-logo_alignment="left">
</div>
<body>
</html>
HTML API 샘플 코드: https://developers.google.com/identity/gsi/web/guides/display-button?hl=ko#html
HTML API 속성 참조: https://developers.google.com/identity/gsi/web/reference/html-reference?hl=ko
통합 코드 생성: https://developers.google.com/identity/gsi/web/tools/configurator?hl=ko
HTML로 구글 웹 로그인 버튼을 구성하려면, 위 샘플 코드와 같이 div 태그에 속성을 설정하면 된다. 이외에도 UI를 직접 봐가면서 커스텀하고 싶다면 '통합 코드 생성' 링크로 들어가서 먼저 UI를 커스텀하고 코드를 받아볼 수 있다.
g_id_onload 태그
- client_id, login_uri, ux_mode, callback 등 버튼이 동작하는 기능에 관련된 속성들을 설정한다.
g_id_sing_in
- size, theme, shape 등 버튼 UI에 대한 옵션에 관한 속성들을 설정한다.
JavaScript 렌더링
<html>
<body>
<script src="https://accounts.google.com/gsi/client" async></script>
<script>
function handleCredentialResponse(response) {
console.log("Encoded JWT ID token: " + response.credential);
}
window.onload = function () {
google.accounts.id.initialize({
client_id: "YOUR_GOOGLE_CLIENT_ID"
callback: handleCredentialResponse
});
google.accounts.id.renderButton(
document.getElementById("buttonDiv"),
{ theme: "outline", size: "large" } // customization attributes
);
google.accounts.id.prompt(); // also display the One Tap dialog
}
</script>
<div id="buttonDiv"></div>
</body>
</html>
JavaScript API 샘플 코드: https://developers.google.com/identity/gsi/web/guides/display-button?hl=ko#javascript_1
JavaScript API 속성 참조: https://developers.google.com/identity/gsi/web/reference/js-reference?hl=ko
JavaScript API로 구글 웹 로그인 버튼을 렌더링할 때는, 사용자가 설정한 id의 div 태그 딱 한줄만 있으면 된다. 그 외 버튼 기능, UI 등은 JavaScript API로 조정한다. HTML API와 마찬가지로 2개의 API가 필요하며 각각 기능과 UI에 관한 속성들로 구성되어 있다.
google.accounts.id.initialize API
- client_id, login_uri, ux_mode, callback 등 버튼이 동작하는 기능에 관련된 속성들을 설정한다.
google.accounts.id.renderButton
- size, theme, shape 등 버튼 UI에 대한 옵션에 관한 속성들을 설정한다.
UX mode
렌더링된 구글 웹 로그인 버튼을 클릭할 때, 로그인 방식을 지정할 수 있다. 팝업 창을 띄우거나, 로그인 페이지로 리다이렉트 시키거나 2가지 방법이 있다.
Popup 모드는 따로 login_uri를 지정할 필요 없이 자동으로 팝업이 닫히며 기존 페이지로 credential이 담긴 response를 반환한다. 이후 처리는 아래 Callback API 항목에서 처럼 callback 함수를 커스텀하여 활용한다.
Redirect 모드는 login_uri를 따로 지정해야하고, 해당 URL에 적절한 컨트롤러가 필요하다. 따라서 JavaScript로 callback 함수를 따로 정의할 필요는 없다.
Popup mode - Client
Popup 모드라면 HTML, JavaScript 둘 중 어떤 것으로 버튼을 구성하던 Callback 함수가 필요하다. 렌더링된 버튼을 클릭하고 구글 계정으로 로그인을 완료했을 때, 사용자 정보를 받을 수 있는 함수를 정의해야한다.
function decodeJwtResponse (token) {
var base64Url = token.split('.')[1];
var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
var jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
return JSON.parse(jsonPayload);
}
/**
ux mode popup
*/
function handleCredentialResponse(response) {
console.log("Encoded JWT ID token: " + response.credential);
const responsePayload = decodeJwtResponse(response.credential);
console.log("ID: " + responsePayload.sub);
console.log('Full Name: ' + responsePayload.name);
console.log('Given Name: ' + responsePayload.given_name);
console.log('Family Name: ' + responsePayload.family_name);
console.log("Image URL: " + responsePayload.picture);
console.log("Email: " + responsePayload.email);
console.log("id: " + responsePayload.id);
$('#memberId').val(responsePayload.email);
$('#memberPwd').val(responsePayload.sub);
$('#memberName').val(responsePayload.name);
$('#memberEmail').val(responsePayload.email);
$('#gSocialYn').val('y');
$('#signUpForm').submit();
}
버튼을 렌더링의 속성 중 callback 속성에 지정한 함수에서 response 파라미터를 받을 수 있는데, response의 속성 중 credential에 사용자 정보가 담겨있다. 이 credential 문자열은 jwt 토큰으로서 사용하려면 파싱과 복호화가 필요하다. 위 코드와 같이 복호화하면 payload에서 id, name, email 등 사용자 정보에 접근이 가능하다. 사용자 정보는 위와 같이 로그인 폼, 회원가입 폼 등의 input 값에 매핑시켜서 처리할 수 있다. 복호화한 credential jwt 토큰의 모든 속성은 아래 링크에서 확인할 할 수 있다.
credential 토큰: https://developers.google.com/identity/gsi/web/reference/js-reference?hl=ko#credential
Redirect Mode - Server
Redirect 모드는 login_uri 속성을 따로 지정해야하기 때문에, 그에 맞는 사용자 정보 처리 컨트롤러가 필요하다. popup 모드에서 작성했던 Callback 함수와 같은 역할을 하며, Credential 파라미터를 바로 받아서 처리해야한다.
@PostMapping("/google")
public String getLogInPage(String credential, HttpServletResponse response) {
try {
// google 계정 credential(jwt 토큰) 디코딩
Map<String, Object> payloadMap = new HashMap<String, Object>();
payloadMap = memberService.decodePayloadInJwt(credential);
String googleEmail = (String)payloadMap.get("email");
String googleSub = (String)payloadMap.get("sub");
// sub(password로 사용할 필드) 암호화
String encodedGoogleSub = passwordEncoder.encode(googleSub);
// member 객체 매핑
Member member = new Member();
member.setMemberId(googleEmail);
member.setMemberPwd(encodedGoogleSub);
member.setGSocialYn("y");
// userName(google eamil)로 UserDetails 가져오기
UserDetails userDetails = userDetailsService.loadUserByUsername(googleEmail);
// 인증 토큰 생성
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(userDetails, encodedGoogleSub, userDetails.getAuthorities());
// 인증
authenticationManager.authenticate(token);
boolean result = token.isAuthenticated();
// 인증 성공 및 실패
if (result) {
SecurityContextHolder.getContext().setAuthentication(token);
// 쿠키 설정
Cookie cookie = new Cookie("userInfo", googleEmail);
cookie.setMaxAge(3600);
cookie.setDomain("localhost");
cookie.setPath("/");
response.addCookie(cookie);
return "redirect:/thyme-board/list";
} else {
return "redirect:/login?error=true";
}
} catch (UsernameNotFoundException e) {
//회원등록되지 않은 구글 계정
return "redirect:/login?error=true";
} catch (Exception e) {
e.printStackTrace();
return "redirect:/login?error=true";
}
}
위 예시 코드와 같이 credential 토큰을 받아 복호화하고, payload의 사용자 정보를 처리하면 된다.
단점
이 방식에는 간결한만큼 큰 단점이 존재하는데, API로 렌더링한 Iframe 버튼이기 때문에 버튼 커스텀이 불가능하다.. 로그인 방식을 유지하면서 버튼을 커스텀 할 수 있는 방법은 다 해봤는데, 마땅치 않았다. 유일하게 가능한 방법을 찾았는데 바로 iframe 버튼을 렌더링하지 않고, 렌더링한 버튼을 클릭했을 때 나오는 로그인 URL을 그대로 사용하는 것이다.
$('#customLogin').on("click", function(){
window.location.href = url;
})
let url = 'https://accounts.google.com/gsi/select?' +
'client_id='+ clientId +
'&ux_mode=redirect'+
'&login_uri='+ 'http://localhost:8080/login/google' +
'&ui_mode=card' +
// '&as=fdTZEXPq87FCWLjHP%2FR%2F2Q+'+
'&g_csrf_token=g_csrf_token' +
'&origin=http://localhost:8080';
위 코드와 같이 렌더링된 구글 소셜 로그인 버튼을 클릭했을 때 나오는 URL을 그대로 복사해서 사용한다. 각 파라미터에 client_id 등 알맞게 값을 넣어주면 되는데, 2개의 파라미터에 문제가 있다.
- as 파라미터: docs에서 아무리 찾아봐도 어떤 역할을 하는 지 모르겠다. 그냥 구별을 위한 고유 값으로 추측하긴 하는데, 없어도 로그인에 문제가 없었다.
- g_csrf_token: 로그인 버튼을 클릭하면 구글 서버에서 발급되는 csrf 토큰인데, 위 코드와 같이 URL을 수동으로 구성하면 crsf 토큰을 얻을 방법이 없다. 파라미터가 없으면 에러가 발생하지만, 파라미터 값을 아무렇게나 집어넣어도 로그인은 가능했다.
as 파라미터는 아예 빼버리고, g_csrf_token 값에 임의의 값을 넣어도 로그인 및 credential 토큰을 통해 사용자 정보에 접근하는 것은 문제가 없었다. 하지만 단순 로그인에 crsf 토큰을 사용하지 않는 것 같아도 csrf 토큰에 임의의 값을 넣는 것은 아무래도 많이 찝찝했다.
마무리
이전에 사용하던 구글 로그인 API가 deprecated 되면서, 관련 에러 로그에서 소개하는 docs 링크를 그대로 따라가다가 알게된 방법이었다. 다른 방식처럼 OAuth2 방식을 이용하는 것은 같으나, access token(credential token) 을 얻기 위해 authCode를 먼저 받아야하는 절차가 필요없었다. 추측하건데, API로 렌더링한 Iframe 버튼을 클릭한 로그인 URL에서 로그인 하는 것으로 authCode를 받는 절차를 생략 혹은 통합한 것 같았다. 그 덕분에 굉장히 간결하게 코딩할 수 있다. 하지만 위에서 서술한 버튼 커스텀이 불가능한 것 때문에 다른 방식으로 개발하여 후술할 예정이다.